Commit Graph

1084 Commits

Author SHA1 Message Date
Oliver Kieselbach 65085ff7ce feat(web): expose EnableIndexDualWrite toggle in admin Global Settings
Closes the operator-facing gap left by M5.d.2: the feature flag was
plumbed server-side (AdminConfiguration model + TableConfigRepository
Store/Map) but the UI had no way to flip it. Without this change the
entire M5.d pipeline was unreachable in production.

Wire-up mirrors the existing allowAgentDowngrade toggle (closest analog
— also a developer/ops-level bool with caveat copy):

- types/adminConfig.ts: add optional enableIndexDualWrite field with a
  doc comment pointing at Plan §M5.d so future maintainers understand
  what toggling it does.
- AdminConfigContext.tsx: add state + setter, hydrate from GET response
  (default false), include in save-spread, include in reset fallback,
  expose via context value + save-useCallback deps.
- AdminConfigSettingsSection.tsx: add prop pair and a checkbox-style
  toggle below the ModernDeployment harmless-IDs input. Copy explains
  the 5 index tables + the 2h safety-net timer + warns that it should
  only be flipped after release-gate verification.
- SectionGlobalSettings.tsx: destructure + forward the new prop pair.

Round-trip is safe: GET (System.Text.Json, camelCase) emits
enableIndexDualWrite, UI spreads it back into PUT body, Newtonsoft
case-insensitive deserialization binds to EnableIndexDualWrite on the
model — so an operator save no longer silently resets the flag.

Verification: npx tsc --noEmit clean, vitest suite 168/168 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:26:48 +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 8e39299da5 feat(backend): M5.d.3 queue-triggered index reconcile consumer
Consumes the telemetry-index-reconcile queue (M5.d.2) and fans each
envelope out into 0–3 index-table rows via IIndexTableRepository
(M5.d.1). Closes the producer→consumer loop; once the
EnableIndexDualWrite flag is flipped, primary commits drive
eventual-consistent index upserts asynchronously.

Components:
- Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues 5.5.4
  package for the isolated-worker QueueTrigger binding.
- IndexReconcileHandler (plain class — not interface-abstracted per
  Plan §M5.d design: DAL abstraction lives at IIndexTableRepository,
  not here). Routing:
    Signal → SignalsByKind (always, 1 row).
    DecisionTransition:
      IsTerminal=true          → SessionsByTerminal
      Taken=true               → SessionsByStage
      Taken=false + reason     → DeadEndsByReason
      ClassifierVerdictId!=null → ClassifierVerdictsByIdLevel
  Up to 3 rows per DecisionTransition envelope (a Taken+terminal+
  classifier transition fans to SessionsByTerminal + SessionsByStage
  + ClassifierVerdictsByIdLevel).
- IndexReconcileFunction thin QueueTrigger wrapper
  (Connection = AzureTableStorageConnectionString). Malformed JSON
  logged + dropped to avoid poison-queue retry loops; transient
  repo errors propagate so the platform retries.
- DI: handler registered as Singleton in Program.cs.

Failure semantics:
- Required-field-missing envelopes (Signal without SessionSignalOrdinal
  or SignalKind; DecisionTransition without StepIndex) → log warn,
  drop. These won't become valid on retry — avoiding the retry loop.
- Transient Table Storage errors → rethrow. QueueTrigger platform
  retries up to 5 times by default, then moves to
  telemetry-index-reconcile-poison for manual investigation.

Deferred to M5.d.4:
- SessionsByStage stale-row pruning: when a session moves from
  Stage-X→Stage-Y, the old Stage-X row stays. Either query-side
  dedupe (per SessionId, max-Ticks RK) or reconcile-timer pruning
  handles this. Deliberately not solved in the handler to avoid
  a query+delete round-trip per Taken transition.
- End-to-end dual-write verification tests (Azurite harness) are
  the M5.d.4 release gate before the flag is flipped.

Tests (+10 in Functions) via capture-only FakeIndexTableRepository:
- Signal happy path (1 SignalsByKind row).
- Signal missing SessionSignalOrdinal / SignalKind → 0 rows (x2).
- DecisionTransition Terminal+Taken+Classifier → 3 rows.
- DecisionTransition Taken non-terminal no-classifier → 1 row
  (SessionsByStage only).
- DecisionTransition dead-end with classifier → 2 rows
  (DeadEndsByReason + ClassifierVerdictsByIdLevel).
- DecisionTransition dead-end without reason → 0 rows (PK-collision
  defense).
- DecisionTransition missing StepIndex → 0 rows (all paths gated).
- Unknown SourceKind → 0 rows.
- Null envelope → 0 rows.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:03:06 +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 31eabbefef refactor(backend): split EventIngestProcessor into thematic partials
The single 1060-LoC file was hard to navigate; partial-class split mirrors
the legacy TableStorageService/IngestEventsFunction pattern and keeps each
file focused on one concern.

- EventIngestProcessor.cs (491) — orchestrator + ctor + DI + ProcessEventsAsync
  + EventIngestResult DTO
- EventIngestProcessor.Classification.cs (212) — ClassifyEvents,
  IsPeriodicOrStallEvent, UpdateSessionStatusAsync
- EventIngestProcessor.Notifications.cs (168) — SendWebhookNotificationsAsync,
  BuildSignalRMessages
- EventIngestProcessor.AppInstall.cs (171) — AggregateAppInstallEvent
- EventIngestProcessor.RuleStats.cs (86) — RecordGatherRuleStatsAsync,
  RecordAnalyzeRuleStatsAsync

Pure mechanical refactor — code is verbatim, only file boundaries moved.
All 596 backend tests green. No changes to behaviour or to legacy
/api/agent/ingest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:13:51 +02:00
Oliver Kieselbach d218a6ca25 feat(backend): M5.b.2 EventIngestProcessor — V2 endpoint event-pipeline parity
Extract the post-parse event-processing tail of IngestEventsFunction into a
standalone EventIngestProcessor service so /api/agent/telemetry runs the same
downstream logic as /api/agent/ingest. Deliberate copy-duplication, not a
shared refactor:

- Legacy /api/agent/ingest is production-hot — it serves live traffic from
  every deployed V1 agent and is NOT touched by this commit. Zero regression
  risk on the existing path.
- EventIngestProcessor is a near-verbatim copy of the legacy pipeline's tail
  (~1000 LoC). Duplication is accepted because legacy /api/agent/ingest is
  scheduled for decommission post-v11 GA (see tasks/todo.md → Follow-Ups →
  Legacy-Agent-Ausmusterung). Until then: bugs fixed in legacy must be ported
  here manually.

Copied pipeline steps (all 1:1 with legacy ProcessIngestAsync lines 150-566):
  StampServerFields → SanitizeEventTimestamps → StoreEventsBatchAsync →
  Index updates fire-and-forget → IME agent version history → ClassifyEvents
  → App install summaries → Geo-location update → UpdateSessionStatusAsync
  (all 7 status branches) → IncrementSessionEventCountAsync → Rule engine
  analysis fire-and-forget + SignalR + stats → Vulnerability correlation with
  WhiteGlove Part-2 merge → IncrementPlatformStatAsync TotalEvents +
  SuccessfulEnrollments → RecordGatherRuleStatsAsync → Diagnostics blob
  update → GetSession for updatedSession → Session age 4h warning → WhiteGlove
  IsPreProvisioned error log → SendWebhookNotificationsAsync (all 3 alert
  paths) → SLA breach evaluation → Admin override detection → ServerAction
  fetch-and-clear with TrackEvent telemetry → BuildSignalRMessages.

Shared (not duplicated, to avoid drift in pure helpers):
  IngestEventsFunction.StampServerFields, SanitizeEventTimestamps,
  RecalculateAppDurations (all internal static, state-less, stable). The
  internal DTOs EventClassification + AppInstallAggregationState are also
  reused — they carry no behaviour, only fields.

IngestTelemetryFunction wiring:
- Return type HttpResponseData → IngestEventsOutput so the V2 endpoint can
  emit SignalR messages for the real-time UI push (matches legacy binding).
- PersistItemsAsync partitions the batch: Events go through the processor
  (full pipeline + AdminAction + pending ServerActions + SignalR); Signal +
  Transition batches skip the processor and read control signals directly
  via a lean ReadControlSignalsAsync helper on ISessionRepository — no point
  firing the full pipeline for a batch that carries no events.
- EventIngestProcessor registered in Program.cs DI.

Known gap (explicitly NOT in this commit): the V2 endpoint lacks a payload
size guard (legacy reads MaxNdjsonPayloadSizeMB from tenant config in
ParseNdjsonRequest). Different payload shape (JSON array vs NDJSON), deferred
to a dedicated follow-up.

All 596 backend tests green (unchanged count — copy path has no new tests
yet; the V2 endpoint's behaviour is validated by the legacy test coverage
through the shared-helper surface + the TelemetryPayloadParser tests from
M5.b.1). Integration coverage lands with M7 test-VM smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:04:11 +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 a0d0178353 feat(agent-v2): gzip request body in BackendTelemetryUploader
Legacy IngestEventsAsync compressed the NDJSON body with gzip (~70%
reduction) — low-bandwidth OOBE links depend on it. The V2 uploader
only benefitted from response-side gzip (AutomaticDecompression on the
mTLS handler) and shipped plain-JSON outbound.

Compress the serialised JSON-array batch with GZipStream, swap
StringContent for ByteArrayContent, advertise Content-Encoding: gzip.
Wire format stays JSON-array — M5 backend contract is unchanged, only
the transport is compressed.

RecordingHttpMessageHandler transparently gunzips captured bodies so
existing parse assertions keep working, and now exposes RawBody for the
new gzip magic-byte / size test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 02:32:30 +02:00
Oliver Kieselbach 9615b4c902 feat(agent-v2): M4.6.ε backend kill-switch + device-block + admin-action plumbing
Closes the last peripheral-parity gap flagged by the user: Legacy's admin
kill-switch / device-block / admin-mark-failed signals flow through the
batch-upload response and drive the same termination / pause paths. Without
this the V2 agent had no way to be remotely stopped or quarantined — it
could only be self-destructed by its own decision engine.

UploadResult extended
- New optional fields on success: DeviceBlocked, UnblockAt, DeviceKillSignal,
  AdminAction, Actions. Factory OkWithSignals(...) for success-with-control.
- Legacy-parity: same semantic as IngestEventsResponse's quarantine / kill /
  admin surface, just riding on the new /api/telemetry/batch response body.

BackendTelemetryUploader.TryReadControlSignalsAsync
- Parses the 2xx response body as JSON (defensive — empty body or malformed
  JSON degrades cleanly to plain Ok). Accepts both camelCase and PascalCase
  field names so an M5 backend can choose either without breaking V2
  agents in the wild.
- Recognises deviceBlocked (bool) + unblockAt (ISO-8601 UTC) + deviceKillSignal
  (bool) + adminAction (string) + actions (ServerAction[]).

TelemetryUploadOrchestrator
- New public event ServerResponseReceived fires on successful uploads that
  carry any control signal. Handlers run after the cursor has moved, so a
  handler exception does not abort or re-run the batch.
- New public IsPaused property — DeviceBlocked sets an internal
  _pausedUntilUtc; subsequent DrainAllAsync calls short-circuit with a
  "device blocked" LastErrorReason until UnblockAt elapses (or indefinitely
  when UnblockAt is null). UnblockAt=null maps to DateTime.MaxValue so the
  comparison stays deterministic without a null branch per tick.

EnrollmentOrchestrator.Transport property
- Exposes the live TelemetryUploadOrchestrator so Program.cs can subscribe
  to ServerResponseReceived without leaking transport internals through
  public orchestrator API.

Program.cs WireTelemetryServerResponse
- Routes ServerResponseReceived through the existing ServerActionDispatcher:
  • DeviceKillSignal → synthesises terminate_session with forceSelfDestruct=
    true + gracePeriodSeconds=0 (kill overrides local SelfDestructOnComplete
    config — Legacy parity, the operator's decision wins)
  • AdminAction → synthesises soft terminate_session respecting local config
  • Actions[] → dispatched directly
  • DeviceBlocked → logged (the transport already paused itself)
- onTerminateRequested extended: reads params["forceSelfDestruct"] and
  flips agentConfig.SelfDestructOnComplete at runtime when set, so the
  cleanup service actually runs.

Tests (+15 in V2.Core.Tests → 384)
- BackendTelemetryUploaderControlSignalsTests: empty body, malformed JSON,
  DeviceBlocked + UnblockAt, PascalCase accepted, DeviceKillSignal,
  AdminAction, Actions array round-trip, non-2xx does not parse signals,
  body with only unknown fields.
- TelemetryUploadOrchestratorServerResponseTests: plain Ok is silent,
  kill-signal raises event once, admin-action forwards field, actions
  forward list, DeviceBlocked pauses → unblock lifts pause → next drain
  uploads the queued item, handler exceptions do not break cursor.

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

With this commit M4.6 closes all known Legacy-vs-V2 peripheral-parity gaps:
agent_kill (DeviceKillSignal → synthesized terminate_session), agent_blocked
(DeviceBlocked → paused drain), portal Mark-Failed/Mark-Succeeded (AdminAction
→ synthesized terminate_session), plus the generic server-queued Actions[].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:56:44 +02:00
Oliver Kieselbach 50c3bf0516 feat(agent-v2): M4.6.δ diagnostic modes + analyzer manager + gather-rules runtime
Closes the final M4.6 batch of peripheral-parity gaps. After this commit the
V2 agent has full feature parity with the Legacy agent around every
capability NOT inside the DecisionEngine itself.

Diagnostic CLI modes (Program.*.cs partials)
- --run-gather-rules: ported 1:1 from Legacy. Resolves TenantId via V2
  TenantIdResolver, creates a fresh ephemeral SessionId, registers it,
  fetches remote config, runs GatherRuleExecutor against all Trigger=startup
  rules (120s cap), uploads the collected events in-memory via
  BackendApiClient.IngestEventsAsync. Never touches the live agent's spool.
- --run-ime-matching <path> [--patterns <file>]: ported 1:1 from Legacy.
  Single-file or directory scan through the same IME log-file patterns the
  live tracker watches, loads patterns from GitHub (fallback: cached
  remote-config.json), compiles regexes with {GUID} substitution + 1s
  timeout, scans + writes ime_pattern_matching.log with per-pattern counts.
- Both modes fork early in Main before orchestrator startup — no side
  effects on the Scheduled Task, no process conflict with the live agent.

AgentAnalyzerManager (V2.Core/Runtime/)
- Ported from Legacy AnalyzerManager. Reads AnalyzerConfiguration from
  AgentConfigResponse.Analyzers directly (no RemoteConfigService
  indirection — the cache is already resolved at call site).
- Initialize() registers LocalAdminAnalyzer / SoftwareInventoryAnalyzer /
  IntegrityBypassAnalyzer gated by Enable* flags; idempotent across
  repeated calls.
- RunStartup() fires on a background task, swallows per-analyzer
  exceptions. Called from Program.cs right after orchestrator.Start so
  analyzers emit through the live TelemetryEventEmitter.
- RunShutdown(int? whiteGlovePart) runs synchronously before the
  diagnostics upload, routing SoftwareInventoryAnalyzer to its overload
  that distinguishes WhiteGlove part 1 baseline from part 2 delta.

EnrollmentTerminationHandler integration
- New optional `analyzerManager` ctor param. RunShutdownAnalyzers runs
  FIRST in the termination sequence (before FinalStatus + diagnostics
  upload) so the analyzers' delta events make it into the final
  diagnostics ZIP. Passes whiteGlovePart=1 on WhiteGloveSealed terminal.

GatherRuleExecutorHost (DefaultComponentFactory nested)
- Runtime gather-rules execution via a new ICollectorHost that wraps
  GatherRuleExecutor. Only registered when remoteConfig.GatherRules has
  entries. Picks up ImeLogPathOverride for file-collector paths.
- Legacy parity: gather rules run as part of the active enrollment session
  alongside the collector hosts, not only via the standalone --run-
  gather-rules diagnostic mode.

Program.cs mode fork (Main)
- --install / --run-gather-rules / --run-ime-matching handled in order
  after --help / --version, before multi-instance guard and self-update
  (those only apply to the normal monitoring mode).

Tests (+8 in V2.Core.Tests → 369)
- AgentAnalyzerManagerTests: toggle-matrix, null-config default behaviour,
  idempotent Initialize, argument-null guards, fake-analyzer injection for
  RunShutdown success/exception/empty-list paths. The three real analyzers
  already have their own parity-tested implementations in V2.Core shared
  with Legacy.

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

M4.6 is now complete. The V2 agent matches Legacy feature parity for:
install mode, self-update, multi-instance guard, clean-exit marker,
previous-exit classification, enrollment-complete ghost-restart detection,
session-age emergency break, AgentMaxLifetime watchdog, BootstrapConfigCleanup,
terminal-stage detection with typed Terminated event, FinalStatus.json +
SummaryDialog launch via UserSessionProcessLauncher, CleanupService self-
destruct (with WhiteGlove-part-1 pause), DiagnosticsPackageService upload,
ServerActionDispatcher (terminate_session / request_diagnostics / rotate_
config callbacks ready for M5 response-path plumbing), Geo/NTP/Timezone
probes, Emergency + Distress reporters, DeliveryOptimizationCollector,
3 analyzers, --run-gather-rules + --run-ime-matching diagnostic modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:10:56 +02:00
Oliver Kieselbach 7e199019ce feat(agent-v2): M4.6.γ diagnostic probes, reporters, DO collector wired
Closes the third batch of peripheral-parity gaps — Legacy's fire-and-forget
startup probes, the two cert-less + authenticated error-reporting channels,
and Delivery-Optimization telemetry all run in V2.

StartupEnvironmentProbes (V2.Core/Runtime/)
- New helper that replicates the Legacy MonitoringService startup diagnostic
  trio: device_location / timezone_auto_set / ntp_time_check.
- Sequential RunAsync(config, logger, emitter): geo lookup → (optional)
  tzutil auto-set → NTP offset check. Each step is best-effort and feeds
  through the orchestrator's TelemetryEventEmitter.
- Geo probe respects EnableGeoLocation; timezone probe respects
  EnableTimezoneAutoSet AND requires an IANA tz from geo. NTP probe always
  runs (diagnostic-only; Warning on offset >60s or failure).
- Program.cs wires this via Task.Run right after orchestrator.Start so a
  slow network never delays the critical path.

EmergencyReporter + DistressReporter (live in Program.cs)
- Previously instantiated nowhere — now built once at startup with the
  shared hardware info. RemoteConfigService ctor takes both so config-fetch
  failures flow to the right channel (auth → distress, network → emergency).
- Initial AuthCertificateMissing distress fires when UseClientCertAuth=true
  but BackendApiClient.ClientCertificate is null — surfaces pre-MDM-
  enrollment dead-ends via the cert-less channel (Legacy parity).
- BackendTelemetryUploader uses the same hardware info (dedup'd the second
  HardwareInfo.GetHardwareInfo call that was duplicated since M4.5.b).

DeliveryOptimizationHost (DefaultComponentFactory nested)
- New ICollectorHost wrapping DeliveryOptimizationCollector with:
  • getPackageStates → ImeLogHost.PackageStates (existing accessor)
  • onDoTelemetryReceived → ImeLogHost.Tracker.OnDoTelemetryReceived pass-
    through (new internal accessor; keeps encapsulation)
  • OnAppStateChanged chained into ImeLogTracker so WakeUp() fires when
    any app enters Downloading or Installing — before any fire we preserve
    the existing handler (the adapter's signal-ingest path must keep
    running unchanged).
- Gated by CollectorConfiguration.EnableDeliveryOptimizationCollector.

Tests (+8 in V2.Core.Tests → 353 → 361)
- StartupEnvironmentProbesTests: event-shape for all three probes + geo
  success/failure/null attempt, timezone success/failure severity, NTP
  small/large offset + failure-with-detail.

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

Note: the "RegistryWatcher standalone admin-action-detector" from the
original M4.6 gap list was a Subagent fabrication — verified no such usage
exists in either Legacy or V2 (the class is only consumed transitively
inside AadJoinWatcher + ProvisioningStatusTracker, both already wired via
DefaultComponentFactory since M4.5.b). Removed from the M4.6.γ scope.

Remaining: M4.6.δ (--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:50:15 +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 02bbb5490c feat(agent-v2): M4.6.α peripheral parity — lifecycle, install, self-update, watchdog
Closes the first batch of peripheral-parity gaps between Legacy and V2 so the
V2 agent is actually deployable/runnable in production. M4 kernel is unchanged
— only wiring and startup helpers.

Deployment + install
- Program.InstallMode.cs: --install mode ported 1:1 (payload copy, schtasks
  Create/Run, bootstrap-config.json + await-enrollment.json persistence,
  HKLM\SOFTWARE\AutopilotMonitor\Deployed marker)
- Configuration/BootstrapConfigFile.cs + AwaitEnrollmentConfigFile.cs
- Scheduled Task target is AutopilotMonitor.Agent.V2.exe (same task name
  as Legacy — V2 replaces Legacy on the device)

Startup guards (Program.Guards.cs)
- Multi-instance guard (IsAnotherAgentInstanceRunning)
- clean-exit.marker writer registered in AppDomain.ProcessExit
- DetectPreviousExit: reads marker + crash_*.log + session files + event log
  → classifies {first_run, clean, exception_crash, hard_kill, reboot_kill}
- CheckEnrollmentCompleteMarker: ghost-restart detection via HKLM Deployed
  key + enrollment-complete.marker → cleanup-retry + exit
- CheckSessionAgeEmergencyBreak: AbsoluteMaxSessionHours watchdog across
  restarts (respects whiteglove.complete pause)
- WriteCrashLog: last-resort crash_YYYYMMDD_HHMMSS.log
- TryRead/Delete bootstrap-config.json + await-enrollment.json helpers

Self-update (SelfUpdater wired for the first time in V2 startup)
- CleanupPreviousUpdate + CheckAndApplyUpdateAsync called BEFORE logger init
- Cached remote-config.json read → BackendExpectedSha256 + AllowAgentDowngrade
- VersionCheckEventBuilder wired: emits agent_version_check event via
  orchestrator.EventEmitter right after Start (when startup markers exist)

Agent max-lifetime watchdog (EnrollmentOrchestrator)
- New ctor param agentMaxLifetime: TimeSpan?
- New typed API: event EventHandler<EnrollmentTerminatedEventArgs> Terminated
  with EnrollmentTerminationReason (MaxLifetimeExceeded | DecisionTerminalStage)
  + EnrollmentTerminationOutcome (Succeeded | Failed | TimedOut)
- M4.6.α fires MaxLifetimeExceeded only; DecisionTerminalStage is the M4.6.β
  hook for CleanupService self-destruct + SummaryDialog launch
- Program.cs wires the event to the shutdown ManualResetEventSlim

Bootstrap-config cleanup (H-2 mitigation)
- BootstrapConfigCleanup.TryDeleteIfCertReadyAsync now actually called after
  RemoteConfigService.FetchConfigAsync (cert probe confirms mTLS works)

SessionIdPersistence extensions
- New session.created companion file (UTC roundtrip) for the emergency break
- whiteglove.complete resume marker awareness
- SessionExists / LoadSessionCreatedAt / SaveSessionCreatedAt / IsWhiteGloveResume
- Delete now clears session.id + session.created + whiteglove.complete

Tests (+26 in V2.Core.Tests → 295 → 321)
- SessionIdPersistenceTests: +8 tests covering the new methods
- EnrollmentOrchestratorMaxLifetimeTests: watchdog fires once, respects null,
  rejects non-positive, is cancelled by Stop (under SerialThreading collection)
- ProgramGuardsTests: 13 tests covering DetectPreviousExit state machine,
  CheckSessionAgeEmergencyBreak (pass/trip/whiteglove-resume/recover-missing-
  session.created paths), TryRead/Delete bootstrap + await-enrollment configs,
  WriteCrashLog format
- V2.Core.Tests gains ProjectReference to V2 exe + V2 exe exposes
  InternalsVisibleTo so the Program.Guards internal statics are testable
  without duplicating code into V2.Core

All 4 suites green: 73 DecisionCore + 321 V2.Core + 507 Agent.Core + 557 Functions
= 1458 tests green.

Still open for M4.6.β: CleanupService terminal-event wiring, ServerActionDispatcher,
DiagnosticsPackageService, SummaryDialog launch + FinalStatusBuilder,
UserSessionProcessLauncher / UserProfileResolver wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:14:48 +02:00
Oliver Kieselbach 9f45b5b7a6 test(v2core): M4.5.c stabilize SignalIngress + DeadlineScheduler threading flakes
Four tests were flaky under full-suite parallelism even though they passed
in isolation (SpinWait/ManualResetEventSlim timeouts expire when the
ThreadPool is contended by unrelated test classes):
- SignalIngressTests.Clock_advance_past_throttle_window_re_arms_emission
- SignalIngressTests.Null_observer_is_supported_and_back_pressure_is_silent
- DeadlineSchedulerTests.Reschedule_same_name_replaces_old_timer
- DeadlineSchedulerTests.RehydrateFromSnapshot_reschedules_each_deadline_past_due

Fix: [Collection("SerialThreading")] on both classes (xUnit runs them
sequentially against each other) + wait-helper/DefaultWaitMs bumped
2000ms→5000ms for threadpool-contention cushion.

Verified: 5× full-suite runs green (287/287 pre-M4.5.b, 295/295 post).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:48:39 +02:00
Oliver Kieselbach 0ee68c6192 feat(agent-v2): M4.5.b runtime entry + DefaultComponentFactory
Wires the V2 agent into a runnable state:

- TenantIdResolver: HKLM\SOFTWARE\Microsoft\Enrollments → AADTenantID (EnrollmentType=6)
- SessionIdPersistence: atomic session.id via File.Replace
- IComponentFactory signature: +ISignalIngressSink +IClock
  (bridges the sink/clock access into factory-built adapters)
- DefaultComponentFactory: 5 kernel hosts (EspAndHelloTracker coordinator,
  DesktopArrivalDetector, AadJoinWatcher, ImeLogTracker+ImeProcessWatcher,
  StallProbeCollector) + 2 peripheral hosts (Performance, AgentSelfMetrics)
  conditional on CollectorConfiguration toggles
- Program.cs: full runtime entry (args → Logger → TenantId/SessionId →
  AwaitEnrollment → BackendApiClient+RemoteConfigService → MtlsHttpClientFactory
  → BackendTelemetryUploader → DefaultComponentFactory → EnrollmentOrchestrator
  → Ctrl+C wait → Stop → exit 0)

Production host choice: EspAndHelloTracker as single coordinator entry
(instead of 4 sub-tracker hosts in parallel) — avoids adapter signal
duplication documented in M4.3 tech-debt. StallProbeHost drives its own
60s idle-tick timer (Legacy MonitoringService.IdleCheckCallback is gone).

Tests: +8 SessionIdPersistenceTests (atomic-write, round-trip, corrupt-
recovery, regeneration, multi-instance resume). V2.Core 287→295, total
1424→1432 green.

Known peripheral gaps (tracked for follow-up M4.6): install mode,
SelfUpdater, ServerActionDispatcher, CleanupService, Geo/NTP/Timezone
wiring, Emergency/DistressReporter wiring, AnalyzerManager, SummaryDialog,
DiagnosticsPackageService, gather-rules/ime-matching modes, watchdog
timers, multi-instance guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:48:24 +02:00
Oliver Kieselbach 925fc707cc fix(v2core): M4.5.a restore ImeTrackerStatePersistence (peripheral capability, not dead code)
M4.5.a hat ImeTrackerStatePersistence faelschlicherweise als Legacy
geloescht. Korrektur: die Klasse persistiert den IME-Collector-internal
State, nicht DecisionEngine-State:

  - LogFilePositionTracker: Byte-Offset pro IME-Logfile (ohne das
    re-parst der Tracker nach Restart von Byte 0 → Event-Spam)
  - SeenAppIds: Phase-Isolation (Device-Phase-Apps duerfen nicht in
    AccountSetup-Phase bleeden)
  - IgnoreList: App-IDs aus abgeschlossenen Phasen, die weiterhin in
    Logs auftauchen koennen
  - Packages (AppPackageStateList): vollstaendiger Install-Progress,
    DO-Telemetry, ErrorPatterns, AppType, AttemptNumber — essentiell
    fuer App-Download-Monitor, Install-Monitor, App-Summaries
  - Phase-Tracking (CurrentPhaseOrder, LastEspPhaseDetected, etc.)

SignalLog-Replay deckt das NICHT ab — der SignalLog persistiert
DecisionSignals (SessionStarted, EspPhaseChanged, HelloResolved, …),
nicht per-App-Install-Progress oder Log-File-Positions.

Restore:
  - ImeTrackerStatePersistence.cs wieder aus git history
  - ImeLogTracker.cs: _statePersistence-Feld + Ctor-Init +
    LoadState/SaveState/DeleteState-Bodies aus 8ca845c^ restored
    (nur die beiden dead using-Imports auf Flows/Completion bleiben
    entfernt, da deren Namespaces tatsaechlich geloescht sind)

Build + Tests gruen (286/287, known pre-existing Flake bleibt fuer
M4.5.c).

Danke an User fuer den Catch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:27:23 +02:00
Oliver Kieselbach 8ca845c9cb refactor(v2core): M4.5.a legacy dead-code cleanup (~9360 LoC removed)
Scope: alle Enrollment/Transport/Runtime-Glue-Files aus dem Legacy-M2-
Import, die durch DecisionEngine+Reducer (M3) und Transport/Telemetry
(M4.1) abgeloest sind und keine aktiven V2-Konsumenten mehr haben
(Program.cs ist noch M2-Skeleton, null Test-Refs auf Legacy).

Delete:
  - Monitoring/Runtime/{MonitoringService, SessionPersistence,
    CollectorCoordinator, PeriodicCollectorManager, AnalyzerManager}
  - Monitoring/Enrollment/{EnrollmentTracker.*cs (4 partials),
    EnrollmentStatePersistence}
  - Monitoring/Enrollment/Flows/ (4 files, Ordner weg)
  - Monitoring/Enrollment/Completion/ (6 files, Ordner weg)
  - Monitoring/Enrollment/Ime/{ImeTrackerStatePersistence, LogReplayService}
  - Monitoring/Transport/{EventSpool, EventUploadOrchestrator}

Fix peripherals (Import-Pruning inline):
  - ImeLogTracker: ImeTrackerStatePersistence-Referenzen entfernt, Load/
    Save/DeleteState sind no-op stubs. V2 resumed ueber SignalLog-Replay
    + SignalAdapter fire-once dedup.
  - AgentSelfMetricsCollector: EventSpool-Param + spool_queue_depth-
    Metric entfernt. V2-Equivalent via TelemetrySpool kommt in M4.5.b.
  - 10 Files: dead using-Imports auf Enrollment.Flows / .Completion
    entfernt (sed-Batch).

Keep (aktive V2-Konsumenten):
  - BackendApiClient, EmergencyReporter, DistressReporter
    → RemoteConfigService.FetchConfigAsync() bleibt auf GetAgentConfig
    bis M5 oder V2-Slim-Client
  - NetworkMetrics (Utility), ImeLogTracker + Ime/-Support (aktiver
    V2-Collector)
  - Peripheral capabilities (per feedback_v2_kernel_only_refactor):
    DiagnosticsPackageService, CleanupService, PerformanceCollector,
    DeliveryOptimizationCollector, SelfUpdater, ServerActionDispatcher,
    RegistryWatcher, UserProfileResolver, UserSessionProcessLauncher,
    VersionCheckEventBuilder, NetworkChangeDetector, Analyzers

Inventory-Greps alle 0 (nur noch README.md + Kommentar-Refs):
  EnrollmentTracker class, CompletionStateMachine, IEnrollmentFlowHandler,
  EventSpool, EventUploadOrchestrator, MonitoringService (Code),
  CollectorCoordinator, PeriodicCollectorManager, AnalyzerManager.
V2-Isolation-Grep (using AutopilotMonitor.Agent.Core): 0.

Build gruen, Tests: 286/287 (pre-existing Flake
SignalIngressTests.Null_observer_is_supported_and_back_pressure_is_silent,
M4.5.c-Scope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:21:47 +02:00
Oliver Kieselbach aca66a79c0 refactor(v2core): M4.4.5.g integration tests (14 M3 scenarios as real V2 runtime tests)
Release-Gate M4.4. Alle 14 M3-Szenarien laufen durch den echten V2-
Agent-Runtime (DecisionEngine + DecisionStepProcessor + SignalIngress +
EffectRunner + Persistence + TelemetryTransport + production-Classifier),
nicht als M3-Harness-Replay.

EnrollmentOrchestratorFixture lockstep-synchronisiert den Fixture-
Replay ueber processor.CurrentState.StepIndex + ingress.Queue-Length:
StepIndex wird erst NACH DecisionStepProcessor.ApplyStep inkrementiert
(inkl. EffectRunner.RunAsync, das synthetische ClassifierVerdictIssued-
Signale postet) — Wait auf signalLog.LastOrdinal allein war race-prone,
weil LastOrdinal in SignalIngress.ProcessItem VOR EffectRunner updated.

Szenario-Coverage (alle gruen):
  01 UserDriven-Happy, 02 UserDriven-HelloTimeout,
  03 SelfDeploying-Happy, 04 SelfDeploying-EspExitUnknown,
  05 WhiteGlove-Inline, 06 WhiteGlove-SignalCorrelated,
  07 WhiteGlove-AntiLoop, 08 WhiteGlove-FalsePositive-LateAADJ,
  09 WhiteGlovePart2-Happy, 10 WhiteGlovePart2-Stuck,
  11 Hybrid-Reboot, 12 ESP-Terminal-Failure,
  13 Late-AADJ-Standalone, + Telemetry-Pipeline-Smoke.

DevPrep-v2-Varianten nicht drin: keine M3-Fixture vorhanden
(follow-up M5/M7).

FixtureLoader als Copy aus DecisionCore.Tests (Plan §2.16 Copy-Not-Share
fuer V2-Isolation). Fakes an System-Boundaries: FakeBackendTelemetry
Uploader, VirtualClock, TempDirectory. Real: Persistence, Ingress,
EffectRunner, Classifiers.

+14 Integration-Tests (273 → 287 gruen). Gesamte Test-Suite:
~287 V2.Core + 73 DecisionCore + 557 Functions = ~917 Tests plus
Legacy Agent.Core 507. Pre-existing Threading-Flakiness in
SignalIngressTests/DeadlineSchedulerTests bleibt (unveraendert,
tracked als known-flake in Orchestration-Testclass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:15:12 +02:00
Oliver Kieselbach a6f515643f refactor(v2core): M4.4.5.f recovery paths (WG part-1 resume + segment quarantine + transport resume)
Plan §2.7 Sonderfaelle 1-3 umgesetzt:

Sonderfall 1 — WG Part-1 → Reboot → Part-2:
Orchestrator erkennt state.Stage == WhiteGloveSealed beim snapshot.Load
und setzt IsWhiteGlovePart2Resume-Flag. Nach ingress.Start (vor
Collectors/Adapters) wird ein SessionRecovered-Signal in den Ingress
gepostet — der Reducer bridged ueber HandleWhiteGlovePart1To2Bridge zu
WhiteGloveAwaitingUserSignIn. Self-Destruct-Disable bleibt Caller-Sache
(Info via IsWhiteGlovePart2Resume exponiert).

Sonderfall 2 — Beschaedigtes Segment:
Snapshot-File existiert aber Load liefert null (Checksum-Mismatch oder
Deserialize-Failure) → SnapshotPersistence.Quarantine + neuer
SegmentQuarantine-Helper bewegen Snapshot/SignalLog/Journal/
EventSequence nach .quarantine/{timestamp}/. Writer werden danach
reinstantiiert (stale LastOrdinal/LastStepIndex → reset auf -1). Fresh
start mit DecisionState.CreateInitial. Observability via
WasStartupQuarantine.

Sonderfall 3 — Transport-Resume:
Kein Code-Aenderung noetig: UploadCursorPersistence + TelemetrySpool
lesen persistierten Cursor im Ctor, Orchestrator resumed automatisch
ab LastUploadedItemId + 1. Wire-Format (PK, RK) garantiert Backend-
Idempotenz. Test verifiziert: seeded Spool mit 3 Items + Cursor@0 →
Resume liefert nur Items 1+2.

+5 RecoveryPathTests (268 → 273 gruen). Helper-Klasse
Persistence/SegmentQuarantine (parallel zu SnapshotPersistence.Quarantine)
als oeffentliche API fuer zukuenftige Recovery-Szenarien.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:56:04 +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 4300a01769 refactor(v2core): M4.4.5.d mTLS HttpClient factory + certificate resolver seam
MtlsHttpClientFactory baut HttpClient/HttpClientHandler mit Client-Cert
attached fuer den BackendTelemetryUploader. Delegiert an
ICertificateResolver-Seam (DefaultCertificateResolver in Prod nutzt
CertificateHelper, Tests liefern Fake mit self-signed Cert).

Factory wirft InvalidOperationException wenn kein Cert gefunden — mTLS
ist Pflicht, Agent darf ohne Cert nicht starten. Decompression (GZip +
Deflate) + 30s Default-Timeout analog Legacy BackendApiClient.

CreateHandler separat exponiert, damit Tests direkt auf dem Handler
asserten koennen (HttpClient._handler ist internal + reflection-fragil
auf net48).

+5 Agent.V2.Core Tests (260 → 265 gruen; self-signed-Cert via
System.Security.Cryptography.CertificateRequest, kein Cert-Store-Touch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:46:48 +02:00
Oliver Kieselbach a72e716862 refactor(v2core): M4.4.5.c collector-host seam + event-bridge wiring
ICollectorHost + IComponentFactory als Seam fuer Collector-Produktion:
Factory liefert fertig-konstruierte Hosts mit der telemetryEmitter.Emit-
Callback bereits als onEnrollmentEvent-Sink gebunden. Orchestrator
ruft Start/Stop/Dispose in korrekter Reihenfolge auf.

Die 5 Collectors (HelloTracker, ShellCoreTracker,
ProvisioningStatusTracker, StallProbeCollector, ModernDeploymentTracker)
haben heavy Production-Deps (Event-Log-Watcher, WMI, Registry) — die
Factory-Seam erlaubt Prod-Impl in Program.cs / Tests nutzen Fakes.

Exception-Swallow-Wrapper im Orchestrator: ein Emitter-Fail (z.B.
Transport disposed) darf den Collector-Thread nicht killen.

ModernDeploymentTracker explizit als bridge-only dokumentiert: kein
SignalAdapter, nur Event-Bridge — validiert durch dedizierten Test,
der prueft dass Events im Transport landen aber SignalLog leer bleibt.

Factory optional (default null): Orchestrator ohne Factory laeuft
weiter ohne Collectors (backwards-compatible zu M4.4.5.b).

+7 Agent.V2.Core Tests (253 → 260 gruen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:41:59 +02:00
Oliver Kieselbach 51f9db6429 refactor(v2core): M4.4.5.b enrollment orchestrator skeleton + lifecycle
Top-Level-Lifecycle-Owner. Start() verdrahtet die Kern-Pipeline
(SignalLog/Journal/Snapshot, TelemetrySpool+Uploader, EventSequence+
Emitter+Timeline+BackPressure, DeadlineScheduler+ClassifierRegistry,
EffectRunner, DecisionStepProcessor, SessionTraceOrdinalProvider,
SignalIngress) und verbindet Scheduler.Fired → ingress.Post-Bridge.

Zirkel EffectRunner-ISignalIngressSink ↔ SignalIngress-Processor via
LazyIngressSinkRelay gebrochen. Snapshot-Recovery-Vorstufe bei Start;
volle Checksum/Replay-Logik kommt in M4.4.5.f.

Shutdown idempotent + reverse-order: Scheduler-Unsubscribe → Scheduler
dispose → Ingress.Stop → Drain-Loop cancel → Terminal-Drain → Final-
Snapshot → Dispose. IQuarantineSink-Impl setzt Flag (Bewegung erst beim
naechsten Start, nicht mid-run).

Drain-Loop nutzt Task.Delay (Wall-Clock) statt IClock.Delay, damit
VirtualClock-Tests nicht in Tight-Loop laufen.

+14 Agent.V2.Core Tests (239 → 253 gruen). Collectors + SignalAdapters
folgen in M4.4.5.c, mTLS-HttpClient-Factory in .d, Pattern-Injection in
.e, Recovery-Pfade in .f.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:37:22 +02:00
Oliver Kieselbach ce62a6527d refactor(v2core): M4.4.5.a decision-step processor + quarantine escalation
Produktions-IDecisionStepProcessor als Applicator-Schicht zwischen
SignalIngress und Journal/Effects/Snapshot. Sequenz: Journal-Append →
EffectRunner (sync-over-async) → Snapshot (best-effort) → State-Forward.

Quarantine-Eskalation: zaehlt aufeinanderfolgende Journal-Failures; ab
Threshold (default 3) Fire-once Call an IQuarantineSink. SignalIngress
schluckt das Throw weiter (Worker-Thread bleibt am Leben), aber der
Orchestrator (M4.4.5.b/f) kennt den Alarm fuer die Quarantine-Bewegung
beim naechsten Start.

+11 Agent.V2.Core Tests (228 → 239 gruen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:28:09 +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 5e28142642 refactor(v2core): M4.4.1 event timeline emitter + sequence counter + back-pressure event bridge
Infrastructure for Kind=Event telemetry in V2.Core. Three consumers share one sink:

- IEventTimelineEmitter impl (EventTimelineEmitter) — expands reducer
  EmitEventTimelineEntry effect parameters {eventType, reason, ...} into a fully-
  populated EnrollmentEvent (Severity auto-derived from eventType suffix:
  _failed->Error, _aborted->Warning, else Info; Source=decision_engine;
  Phase=Unknown per feedback_phase_strategy; ImmediateUpload=true for terminal
  events). Timestamp comes from the signal's OccurredAtUtc, not wall clock,
  keeping replay deterministic.
- IBackPressureObserver impl (BackPressureEventObserver) — surfaces signal-
  ingress back-pressure as agent_trace "ingress_backpressure" events with
  Severity=Warning, carrying origin + channelCapacity + queueLength +
  blockDurationMs in Data. Routes via the shared emitter, NOT through the
  ingress channel (would deadlock, per §2.1a).
- (future M4.4.5 wiring) collector onEventCollected callbacks bind
  Action<EnrollmentEvent> directly to TelemetryEventEmitter.Emit.

Central pieces:
- EventSequenceCounter (thread-safe Interlocked-locked counter) with
  EventSequencePersistence — atomic temp-rename writes to
  {SessionDir}/Telemetry/event-sequence.json, flush-to-disk per §2.7 L.12.
  Starts at 1, seeds from file on recovery; persists every Next().
- TelemetryEventEmitter — assigns Sequence + RowKey
  ({Timestamp:yyyyMMddHHmmssfff}_{Sequence:D10}), serializes via Newtonsoft
  (PascalCase wire-format, byte-identical to Legacy; backend uses
  PropertyNameCaseInsensitive ingest), enqueues TelemetryItemDraft Kind=Event
  to ITelemetryTransport. PartitionKey = {tenantId}_{sessionId}.

Minor API change: IEventTimelineEmitter.Emit gains DateTime occurredAtUtc
(previously only parameters + state). EffectRunner already has
stepOccurredAtUtc in scope — forwarded as one-line change. FakeEventTimelineEmitter
test fake updated accordingly; no existing tests reference the old signature.

+33 tests (1280 -> 1313 green): sequence monotonicity + recovery-seed +
thread-safety, atomic persistence roundtrips + corrupt-file fallback, wire-
format field shape (EventType/SessionId/TenantId/Sequence/EventId/RowKey),
severity auto-derivation, reason-into-Message + Data propagation, Phase stays
Unknown for terminal events, RowKey format, immediate-flush forwarding,
back-pressure event shape + clock-driven timestamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:40:59 +02:00
Oliver Kieselbach b8b5f22a61 refactor(v2core): M4.4.0 signal ingress (blocking-collection worker + back-pressure obs)
Single-writer SignalIngress per Plan §2.1a: dedicated Thread drains a bounded
BlockingCollection<IngressItem> (cap 256), assigns SessionSignalOrdinal +
SessionTraceOrdinal, appends to SignalLog before invoking the reducer, then
hands the DecisionStep to IDecisionStepProcessor (Orchestrator impl in M4.4.5).

Back-pressure observed via TryAdd(0) in Post(); blocking fallback measures
duration and notifies IBackPressureObserver, throttled 1x/min/origin. The
observer emission path deliberately bypasses the signal channel (would
deadlock). Real Events-Bridge wiring lands in M4.4.1 once EventSequenceCounter
exists.

New abstractions (all in V2.Core.Orchestration, §2.11-conformant):
- ISessionTraceOrdinalProvider (thread-safe Interlocked counter, seedable for recovery)
- IDecisionStepProcessor (post-Reduce sink; Orchestrator M4.4.5 implements)
- IBackPressureObserver (M4.4.1 wires to real Events-Bridge)

SignalIngress implements the existing ISignalIngressSink from M4.2 directly so
EffectRunner can re-ingest ClassifierVerdictIssued synthetic signals without a
separate adapter.

+24 tests (1256 -> 1280 green): lifecycle, ordinal monotonicity + recovery,
SignalLog-before-Reduce ordering, state progression across reduces, ApplyStep
exception resilience, ISignalIngressSink path, back-pressure (fast/slow,
throttle same/different origin, clock-rearm, null observer, observer
exceptions), concurrent producer ordinal monotonicity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:22:40 +02:00
Oliver Kieselbach 4ad23d4b0a refactor(v2core): M4.3.5 signal adapters (esp-and-hello coordinator + stall-probe)
Plan §2.1a / §2.2 — Abschluss M4.3 mit dem Production-Path-Coordinator plus
StallProbe. Damit ist die SignalAdapter-Schicht für alle Decision-relevanten
Collectors komplett.

New:
- EspAndHelloTrackerAdapter (Coordinator — Production-Wiring Target in M4.4)
  - HelloCompleted → HelloResolved / HelloResolvedPart2
  - FinalizingSetupPhaseTriggered → EspPhaseChanged (phase=FinalizingSetup)
  - WhiteGloveCompleted → WhiteGloveShellCoreSuccess
  - EspFailureDetected → EspTerminalFailure (merged aus ShellCore + Provisioning)
  - DeviceSetupProvisioningComplete → DeviceSetupProvisioningComplete
  - Independent per-kind fire-once dedup für alle 5 Signal-Kinds
- StallProbeCollectorAdapter
  - EspFailureDetected → EspTerminalFailure (SourceOrigin=StallProbeCollector
    unterscheidet von ShellCore/Provisioning/Coordinator — wichtig für
    Attribution via 4 Scan-Source-Stall-Classifier)

Design-Note:
- Coordinator vs. Sub-Tracker-Adapter: Sub-Tracker sind in EspAndHelloTracker
  private; Production nutzt NUR den Coordinator-Adapter. Sub-Tracker-Adapter
  aus M4.3.1-3 bleiben für Single-Tracker-Wiring-Szenarien und Tests.
- Mapping-Logik-Duplikation zwischen Coordinator + Sub-Tracker-Adaptern ist
  M4.5-Cleanup-Kandidat (SignalAdapterEmitters-Helper oder gemeinsame
  Mapping-Interfaces). ~50 LoC Duplikation aktuell akzeptiert.

Tests (+12, 135 total V2.Core.Tests):
- EspAndHelloTracker: Hello/Finalizing/WhiteGlove/EspFailure/DeviceSetupComplete
  je 1 Test + Part2-Mode + all-5-in-one-session dedup + null-args
- StallProbe: Happy-Path, empty-reason-fallback, fire-once, null-args

Testsuite: 1244 → 1256.

M4.3 komplett — 8 SignalAdapter + 1 StallProbe für alle Decision-relevanten
Collectors. ModernDeploymentTracker (nur Events, keine Signals) wird in M4.4
Orchestrator-Wiring direkt an TelemetryTransport gebridged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:02:14 +02:00
Oliver Kieselbach e97eb08ec5 refactor(v2core): M4.3.4 signal adapter (ime log tracker)
Plan §2.1a / §2.2 — schwerster Adapter: ImeLogTracker mit Action-Property-
Callbacks (nicht Events); 3+ Signal-Mappings + Phase-Value-Dedup + per-App-
Terminal-State-Dedup.

New:
- ImeLogTrackerAdapter
  - OnEspPhaseChanged(phase) → EspPhaseChanged (dedup per distinct phase value,
    §2.1a Idempotenz-Anforderung; null/empty → skip)
  - OnUserSessionCompleted() → ImeUserSessionCompleted (fire-once)
  - OnAppStateChanged(app, old, new) → AppInstallCompleted (Installed/Skipped/
    Postponed) oder AppInstallFailed (Error); non-terminal states ignoriert;
    per-AppId Dedup
- Chain-preserve: vorherige Action-Handler bleiben callable (Orchestrator kann
  diagnostische Callbacks parallel wiren); Dispose restauriert wenn wir noch
  der current handler sind
- Delegate-instance caching (_our* Felder) weil implizite method-group-conversion
  jedes Mal neue Delegate-Instanz baut → ReferenceEquals in Dispose sonst fail

NICHT Teil M4.3.4 (Tech Debt noted):
- WhiteGloveSealingPatternDetected Signal-Emission — ImeLogTracker hat keinen
  öffentlichen Pattern-Match-Hook; LastMatchedPatternId ist Property. Re-Mapping
  braucht entweder Polling-Adapter oder Tracker-Action-Surface-Erweiterung.
  Reducer-M3-Szenarien laufen ohne das Signal grün; Add-on in M4.4/M4.5.

Tests (+16, 123 total V2.Core.Tests):
- EspPhase first-emit / repeat-dedup / distinct-values / null-skip
- UserSessionCompleted fire-once
- AppState Theory (4 terminal states → 2 kinds)
- Non-terminal skipped; per-app fire-once
- Different apps independent; null-app ignored
- Prior-Action chain-preserve
- Dispose restores prior handler
- Null-args throw

Testsuite: 1228 → 1244.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:56:40 +02:00
Oliver Kieselbach 3c1fca8089 refactor(v2core): M4.3.3 signal adapter (provisioning-status tracker)
Plan §2.1a / §2.2 — ProvisioningStatusTracker mit 2 Events auf 2 DecisionSignalKinds.

New:
- ProvisioningStatusTrackerAdapter
  - DeviceSetupProvisioningComplete → DeviceSetupProvisioningComplete
    (liest GetProvisioningCategorySnapshot() — deviceSetupResolved in Payload)
  - EspFailureDetected → EspTerminalFailure (SourceOrigin unterscheidet von
    ShellCoreTracker — wichtig für Multi-Source-Attribution im Journal)

Dual-Source-EspTerminalFailure-Handling (ShellCore + ProvisioningStatus):
Duplicate Signals sind erwartbar und OK — der Reducer akzeptiert die erste,
weitere landen als Dead-End-Transitions im Journal. Adapter-Dedup ist
tracker-lokal (pro Instanz).

Tests (+5, 107 total V2.Core.Tests):
- DeviceSetupComplete → DeviceSetupProvisioningComplete Signal
- EspFailure → EspTerminalFailure mit distinct SourceOrigin
- Empty failureType → "unknown"
- Per-kind dedup
- Null-args throw

Testsuite: 1223 → 1228.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:50:21 +02:00
Oliver Kieselbach 03b27e1377 refactor(v2core): M4.3.2 signal adapter (shell-core tracker)
Plan §2.1a / §2.2 — ShellCoreTracker verbindet 3 Events mit 3 DecisionSignalKinds.

New:
- ShellCoreTrackerAdapter
  - FinalizingSetupPhaseTriggered → EspPhaseChanged (phase=FinalizingSetup, reason propagated)
  - WhiteGloveCompleted → WhiteGloveShellCoreSuccess
  - EspFailureDetected → EspTerminalFailure (failureType in payload, "unknown" fallback)
- Per-kind Dedup-Flags (independent fire-once pro Signal-Kind)

NOTE: ModernDeploymentTracker ist NICHT Signal-relevant (emittiert EnrollmentEvents
via onEventCollected callback → Events-Tabelle-Pipeline). Wird in M4.4 Orchestrator
direkt an TelemetryTransport als Kind=Event gebridged, nicht als SignalAdapter.
Event 509 (WhiteGlove Start) war in Legacy Scoring-Factor (+15); per M3-Erkenntnis
wurde das Weight in V2 entfernt → keine Signal-Emission nötig.

Tests (+7, 102 total V2.Core.Tests):
- Finalizing → EspPhaseChanged mit korrekter EnrollmentPhase.FinalizingSetup
- WhiteGloveCompleted → WhiteGloveShellCoreSuccess
- EspFailure → EspTerminalFailure mit failureType
- Empty failureType → "unknown" fallback
- Per-kind independent dedup (3 Events → 3 Posts, Duplicates gefiltert)
- All-3-in-one-session flow
- Null-args throw

Testsuite: 1216 → 1223.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:48:29 +02:00
Oliver Kieselbach 5a858cb4c5 refactor(v2core): M4.3.1 signal adapters (desktop-arrival, aad-join, hello)
Plan §2.1a / §2.2 — 3 erste SignalAdapter pro Collector. Sub-Split analog M3.x.

New in V2.Core.SignalAdapters:
- DesktopArrivalDetectorAdapter → DesktopArrived / DesktopArrivedPart2
- AadJoinWatcherAdapter → AadUserJoinedLate / UserAadSignInComplete
  (nur AadUserJoined-Event gemapped; PlaceholderUserDetected ist diagnostic,
   nicht Decision-relevant — dokumentiert)
- HelloTrackerAdapter → HelloResolved / HelloResolvedPart2 (payload trägt
  HelloOutcome: completed|skipped|timeout|not_configured|wizard_not_started)

Adapter-Pattern:
- internal sealed class : IDisposable
- Ctor: Collector + ISignalIngressSink + IClock + optional part2Mode
- Subscribe auf Collector-Event; dedup-Flag (fire-once)
- TriggerFromTest() internal hook für Tests (umgeht Event-Plumbing)
- Dispose() unsubscribed
- Keine Modifikation der Collector-Files (§2.11 transitional-SKIP bleibt)

PII-Guard in AAD-Adapter: nur userDomain im Payload, nicht full email.

New tests (+21, 95 total V2.Core.Tests):
- Trigger → Post mit korrektem Kind/Evidence/Payload
- part2Mode → Part-2-Signal-Kinds
- Duplicate-Trigger → Dedup
- Null-Args → ArgumentNullException
- HelloOutcome-Varianten (Theory, 5 values)
- AAD PII-Guard (full email NICHT in payload)

Pattern-Erkenntnisse:
- Collectors sind meist internal — Adapter sind auch internal
  (InternalsVisibleTo Agent.V2 + V2.Core.Tests deckt Usage)
- Evidence.Derived braucht mind. einen DerivationInput — Adapter setzen
  sinnvolle Detection-Source-Keys
- Part-1/Part-2 via bool-Ctor-Param (einfacher als 2 Klassen)

Testsuite: 1195 → 1216. V2-isolation clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:40:00 +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 3f94254673 refactor(v2core): M4.1 generic telemetry transport (spool + orchestrator)
Plan §2.7a / L.10 — ein Spool für Events + Signals + DecisionTransitions,
TelemetryItemId als typ-unabhängiger Upload-Cursor.

New in V2.Core.Transport.Telemetry:
- TelemetryItem + TelemetryItemKind + TelemetryItemDraft (immutable DTOs,
  StringEnum-converted Kind für forward-compat)
- TelemetryItemSerializer (Newtonsoft-Ctor-Bind, JSONL)
- TelemetrySpool (disk-backed JSONL queue, monotonic TelemetryItemId +
  SessionTraceOrdinal=TelemetryItemId for session items, WriteThrough+Flush
  per Enqueue, tail-corruption tolerance, restart recovery of counters)
- UploadCursorPersistence (atomar temp+rename, parse-fail → cursor=-1)
- IBackendTelemetryUploader + UploadResult (Ok/Transient/Permanent) —
  abstraction keeps orchestrator HTTP-agnostic; real BackendApiClient impl
  comes with M4.4 wiring
- TelemetryUploadOrchestrator (Drain loop: batched, retry 100/400/1600ms
  backoff via IClock, permanent-fail skips retry, idempotent resume via
  spool cursor, exception→transient)
- DrainResult + ITelemetryTransport interface

New tests (+28, 56 total in V2.Core.Tests):
- Spool: monotonic ids, session-scoped trace-ordinal, sofort-flush, Peek
  respects cursor + max, MarkUploaded regression no-op, restart recovery,
  corrupt-tail-scan tolerance
- Orchestrator: happy drain, empty-spool noop, batching, 3-retry happy,
  exhaust→fail, permanent→no retry, exception→retry, resume after transient,
  cursor persists across restart, double-drain idempotent, cancel, dispose

Testsuite: 1149 → 1177. V2-isolation clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:47:14 +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