Commit Graph

774 Commits

Author SHA1 Message Date
Oliver Kieselbach ca22be5cd0 notifications: dedupe duplicate Pre-Provisioning Completed webhook + traffic-light icons
The V2 agent emits whiteglove_complete twice (raw Shell-Core event 62407 +
engine timeline event, ~1s apart). The Pending status write was not
idempotent (terminal guard allowed Pending->Pending as a "transition") and
the ingest fallback additionally forced the transitioned flag, so tenants
received the Teams card twice (HSD report). Pending->Pending is now refused
in IsTerminalTransitionAllowed, and the ingest fallback distinguishes an
already-sealed session (duplicate, no notification) from a genuine write
failure.

Notification titles now use neutral traffic-light circles (green/yellow/red)
instead of rocket/checkmark/cross/siren emojis (customer feedback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:13:19 +02:00
Oliver Kieselbach 4def73f193 rules: reserve the built-in rule-ID namespace against tenant custom rules
Tenant rule IDs were free-form with only a point-in-time existence check,
so a customer could occupy a gap or retired ID like ANALYZE-SEC-002; a
later built-in with that ID would then silently shadow the tenant's rule
at merge time (global wins, tenant copy skipped) and their RuleStates
would start toggling our rule.

Enforcement: RuleIdPolicy (Shared) reserves (ANALYZE|GATHER)-<CAT>-<NUM>
case-insensitively, with the CUSTOM category as the sanctioned tenant
namespace (we never ship built-ins in a category named CUSTOM). Both
rule services reject reserved IDs on create AND on the custom-branch of
update (updates upsert, so PUT was a bypass); update endpoints now map
InvalidOperationException to 409 like create. Web forms mirror the check
inline; MCP validate_rule emits a warning finding and the authoring
guide now documents the reservation (guide templates moved to
GATHER-CUSTOM-101/ANALYZE-CUSTOM-101 — the old numeric examples would be
rejected). No existing prod custom rule violates the pattern (verified
against all 13 tenant-partition rows), so nothing needs grandfathering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 14:15:43 +02:00
Oliver Kieselbach 75197a4184 Logparser pattern testing with the agent's exact .NET engine (test_log_pattern) (#125)
* updated commands

* feat(backend): logparser pattern test endpoint with agent-exact semantics

POST /api/rules/gather/test-pattern evaluates a logparser gather-rule regex
against pasted sample lines exactly the way the agent would on the device.
Motivated by a field incident: a customer verified a pattern with a PHP regex
tester and the .NET engine matched differently.

- CmTraceLogParser moves Agent -> AutopilotMonitor.Shared.Logging so agent
  (IME tracker, logparser collector, stall probe, IME matching mode) and
  backend run the SAME parsing implementation. Pure mechanical move, agent
  behavior unchanged (V2.Core suite 2147/2147 green).
- TestLogPatternFunction mirrors LogParserCollector verbatim: Regex(Compiled,
  1s timeout, NO IgnoreCase -> case-sensitive), first match per line, group
  "0" excluded / unsuccessful groups omitted, format cmtrace (default, match
  against parsed message) vs text (raw line), per-line outcomes incl.
  parse_failed/regex_timeout, and the agent's "set parameter format=text"
  hint verbatim. Pure compute, no tenant data. Caps: 200 lines, 8KB/line.
- Policy: TenantAdminOrGlobalReader (same tier as the analyze dry-run).
- Tests pin the parity showcases: case-sensitivity, envelope invisibility in
  cmtrace mode, optional-group omission, format hint, request validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): test_log_pattern tool + logparser lint and guide hardening

- test_log_pattern: wraps POST /api/rules/gather/test-pattern — tests a
  logparser regex against pasted sample lines with the agent-exact .NET
  semantics (case-sensitive, cmtrace/text mode). Description steers clients
  away from JS/PHP/Python regex testing.
- validate_rule: logparser parameter lint (missing/non-compiling pattern,
  unknown format value = silently cmtrace on the agent) + info pointing to
  test_log_pattern; nextStep for valid logparser drafts routes there too.
- rule_authoring_guide: logparser section now states the matching semantics
  (case-SENSITIVE, .NET, first match per line, cmtrace default) and two new
  pitfalls (engine mismatch, plain-text log under cmtrace default).
- Catalog snapshot + handler/validator tests extended (MCP suite 532 green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(okf): test-pattern endpoint + shared CmTraceLogParser in rule authoring surface

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 22:48:10 +02:00
Oliver Kieselbach 3c1229a301 MCP rule authoring: deterministic context, validate_rule, and session dry-run (#124)
* feat(backend): dry-run endpoint for draft analyze rules (rule authoring)

POST /api/rules/analyze/dryrun evaluates a DRAFT analyze rule against one
session's events and returns a full diagnostic trace: per-condition
matched/evidence (incl. per-eventType occurrence counts), per-factor
confidence breakdown, verdict, and the would-be MatchedConditions map for
{{token}} interpolation preview. Persists nothing: no RuleResult row, no
session-status change, no notifications.

- RuleEngine.DryRun.cs: DryRunRuleAsync / DryRunEvaluate share the exact
  evaluators (EvaluatePrecondition/EvaluateCondition/EvaluateConfidenceFactor)
  with the production path but evaluate ALL conditions instead of breaking on
  the first failed required one; parity with EvaluateRule is pinned by tests.
- DryRunAnalyzeRuleFunction: TenantAdminOrGlobalReader (read-in-effect POST),
  GA/GlobalReader cross-tenant via session tenant resolution, 1 MB body cap,
  structural draft validation (unknown source/operator, correlation without
  joinField, invalid regex, non-evaluable confidenceFactor DSL) with
  actionable error messages.
- EndpointAccessPolicyCatalog: register the new route.
- RuleEngineDryRunTests: trace semantics, production parity matrix,
  side-effect freedom (VerifyNoOtherCalls), draft validation matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): rule-authoring resources — guide, schemas, guardrails

Deterministic authoring context for customer AIs, replacing chunked
search_docs retrieval for rule creation: get_resource (and the MCP-protocol
resources) now serve

- rule_authoring_guide: hand-written, complete authoring knowledge (collector
  types, triggers, condition sources, operators, confidence model incl. the
  exact factor-condition DSL shapes, {{token}} interpolation order, guardrail
  matching semantics, pitfalls, validate->dry-run workflow)
- rule_schemas: the JSON Schemas (2020-12) for gather + analyze rules
- rule_guardrails: the on-device collection allowlists

Schemas + guardrails are generated into rule-authoring.generated.ts by a new
combine.js emitter (same pattern as the web guardrails mirror; rules/ stays
the single source of truth and CI guardrails-in-sync guards the file).
rule-authoring-drift.test.ts additionally pins generated == repo source,
guide enums == schema enums, and the get_resource size cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): validate_rule + test_analyze_rule tools and author-rule prompt

Closes the authoring loop for customer AIs: draft -> validate -> dry-run
against a real session -> iterate -> create in the portal.

- validate_rule (MCP-local, no backend call): ajv 2020-12 validation against
  the baked schemas, guardrail pre-flight mirroring the agent matching
  semantics (hive-stripped segment-bounded registry/file prefixes,
  whitespace-bounded WMI, exact commands, /-bounded event log channels, hard
  blocks), and semantic lint for schema-valid-but-dead rules (unreachable
  threshold, non-evaluable factor DSL, unknown eventTypes, unanchored
  not_regex allowlists, unresolvable {{tokens}}, trigger cross-fields,
  outputEventType conventions/collisions).
- test_analyze_rule: calls the new POST /api/rules/analyze/dryrun and adds
  the {{token}}-interpolated explanation/remediation preview via the
  existing interpolate-rule-template.
- author-rule MCP prompt guiding scenario -> resources -> draft -> validate
  -> dry-run.
- Both tools are visible to every role (backend enforces
  TenantAdminOrGlobalReader on the dryrun route); catalog snapshot updated.
- deps: ajv + ajv-formats promoted to direct dependencies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(okf): rule authoring surface concept doc

docs/rules/rule-authoring-surface.md: the AI-assisted authoring stack
(deterministic MCP resources via combine.js baking, MCP-local validate_rule
layers, the dry-run trace twin with its parity + side-effect invariants, and
what is deliberately absent); registered in index.md, logged 2026-08-05.

Customer docs (rules/ai-assisted-rule-authoring.md + SUMMARY + FAQ entry) are
committed in the autopilotmonitor-docs repository separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 17:24:28 +02:00
Oliver Kieselbach 9ba67162aa fix: session duration inflated by inherited tz-skewed IME log timestamps (#123)
Session df1fcf47 (WG Part 1, real ~1h16m) reported 8h15m: one do_telemetry
event inherited an unrelated, 8h-shifted IME log line timestamp via
LastMatchedLogTimestamp, and the backend re-anchored StartedAt on it.

Three layers:
- Agent: OnDoTelemetryReceived now carries the source-line timestamp; the
  DO-collector poll path passes null and the adapter stamps the clock
  instead of inheriting whatever line matched last.
- Backend: StartedAt alignment and duration-start selection reject
  earliest-event timestamps more than 2h before the current anchor
  (MaxStartedAtBackwardShift), on all four mutation sites incl. the
  SessionsIndex dual-write decision.
- Parser: CMTrace time fields with a UTC-bias suffix ("+480") now parse
  and honor the writer-declared offset instead of failing to match.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:54:41 +02:00
Oliver Kieselbach 9d8b0fc254 feat: tenant-config field schema - MCP writes get types right on the first attempt (#121)
Follow-up to the E2E finding (95 vs 95.0): a model driving update_tenant_config
had to guess field names and JSON types from a config read. New reflection-
generated schema surface makes guessing unnecessary:

- Backend: GET config/fields-schema (GlobalAdminOnly) - built from the
  TenantConfiguration model + the patch service deny-lists at runtime, so it can
  NEVER drift from what the patch endpoint enforces. Per field: camelCase name,
  JSON type (string/boolean/integer/number), nullability (null clears), format
  hints (json / date-time), writability + deny reason, GA-only marker (phase 2),
  revert-protected marker. Literal route; FindPolicy literal-over-template
  precedence pinned by test (same mechanism as config/all).
- MCP: new strictGa READ_ONLY tool get_tenant_config_schema; update_tenant_config
  description + GA instructions headline steer the model to call it before
  composing a patch.
- Tests: drift canary (every model property appears exactly once), deny-list
  consistency, type exemplars incl. the decimal SLA column class, policy +
  route-precedence pins, MCP catalog/registration/request-shape. Backend 3854
  green, vitest 465 green, tsc clean.

Deploy order: backend first, then MCP redeploy.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 17:33:04 +02:00
Oliver Kieselbach 3628c7bc79 fix(backend): revert rehydration - whole-valued double columns must not become Int32
Prod finding during the first live E2E (gktatooine): revert_tenant_config failed
fail-closed with "could not be rehydrated". Root cause: JSON cannot distinguish
95.0 from 95, so the snapshot rehydrator turned the whole-valued SLA rate columns
(decimal? in the model, double in storage) into Int32 - and TableEntity.GetDouble
throws on the type mismatch. The rehydrator now forces GetDouble for columns the
model declares as decimal/double/float, mirroring the existing DateTime handling.

The roundtrip canary test is hardened into a reflection filler that sets EVERY
writable property non-default (with a deliberately whole-numbered decimal), so no
column type can escape the fidelity check again; plus a focused pin on the exact
prod failure. Suite 3849 green. No data was harmed: the fail-closed guard refused
the write, the snapshot itself is intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 14:45:11 +02:00
Oliver Kieselbach 395fdd49df Transactional tenant-config writes via MCP + ConfigurationBackups safety net (#120)
* feat(backend): pre-write config backup net - every tenant/admin config save snapshots first

New ConfigurationBackups table (PK=tenantId or GlobalConfig, RK=reverse-ticks+guid,
newest-first) holds the raw stored row as EntityJson before any overwriting save of
TenantConfiguration or AdminConfiguration. Hook lives in TableConfigRepository, the
single choke point all writers funnel through - fail-soft (a backup hiccup never
breaks a save), skips row creations and noise-only changes (LastAuthClientId flips),
prunes to the newest 2 per partition. Source/reason tagging via a new
SaveTenantConfigurationAsync overload (not optional params - Moq expression trees,
CS0854); portal-put/plan/auth/app-homing/offboard call sites tagged.

Offboarding: backup partition wiped in the bulk phase (TenantPartitionTables) and by
the marker-cleanup tombstone sweep; the GlobalConfig partition never matches a tenant
GUID and survives. Table registered in TableNames.All + CriticalBackupTables.All.

Groundwork for the transactional MCP config-write flow (patch/revert endpoints follow).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(backend): transactional tenant-config field patch + revert with verified writes

New GA-only surface (policy catalog: GlobalAdminOnly + RouteParam):
- PATCH config/{tenantId}/fields - field-level patch through TenantConfigPatchService:
  fresh ETag read -> field gate (deny-list incl. plan/trial, HomedAppClientId,
  auth provenance; unknown fields 400 with the name) -> patch onto a clone ->
  shared validation -> fail-CLOSED backup -> CAS replace -> fresh re-read ->
  verify that EXACTLY the intended fields changed -> automatic ETag-conditional
  rollback on drift (catches Store/Map serialization asymmetry).
- POST config/{tenantId}/revert - restore from a ConfigurationBackups snapshot
  (latest by default); snapshots current state first (revert is revertible),
  preserves plan/homing/provenance fields unless includeProtectedFields.
- GET config/{tenantId}/backups - metadata + masked diff only, never EntityJson.

Supporting changes: IConfigRepository gains GetTenantConfigurationWithEtagAsync +
TryReplaceTenantConfigurationAsync (412 -> false, no implicit backup hook);
validators moved from the PUT into shared TenantConfigValidation (PUT re-pointed,
forwarding shims keep test call sites); GET config/{tenantId} honors ?view=redacted
so a GA's MCP session never pulls clear-text secrets into model context.
Caller-tier deny-lists (TenantAdmin/DelegatedAdmin) ship now for the phase-2 flip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): tenant-config write tools - update/revert with snapshot-backed verified writes

First mutating surface on the MCP server, strictGa (real Global Admin) only:
- get_tenant_config: full config read, ALWAYS ?view=redacted (secrets never
  reach model context; pinned by config-write-tools tests)
- update_tenant_config: field-level patch via PATCH config/{tenantId}/fields;
  required reason; response = applied fields + masked diff + backupId
- list_tenant_config_backups: snapshot metadata + masked diff only
- revert_tenant_config: restore latest/named snapshot; protected fields stay
  current unless includeProtectedFields; revert is itself revertible

New MUTATING annotation + TenantGuidSchema (path-traversal guard) in shared.ts;
role-aware instructions headline (non-GA keeps the exact READ-ONLY wording);
catalog snapshot test extends GA_FULL + new CONFIG_WRITE_GA_STRICT difference
list (Global Reader/tenant/delegated see none of the four).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(backend): de-flake session-deletion heartbeat test - condition-gated handler instead of wall-clock window

The heartbeat test held the handler for a FIXED 350ms and expected the 50ms
heartbeat timer to tick >=3 times inside that window. On loaded CI runners the
heartbeat Task.Delay stretches disproportionately to the handler delay, the
handler completes with <3 heartbeats fired, and the awaited condition can never
become true afterwards (CI failure on PR #120). The handler now holds the
message in-flight UNTIL the test observed 3 heartbeats (with ct + own 15s
deadline as termination guards), so the assertion is reached deterministically
regardless of scheduler jitter. Verified: 12/12 local runs green, full suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 14:30:05 +02:00
Oliver Kieselbach 46965af290 feat(backend): finalize + arm the offboarding farewell email
Replace the [DRAFT] placeholder with the final copy (thank-you, gentle
feedback pointer to GitHub/LinkedIn, welcome-back note, last-email notice)
and remove the OffboardFarewellEmailArmed scaffolding gate. Tests move from
the disarmed-by-default contract to the armed contract (gate ordering,
never-throw, template pins). GitHub links in both email templates now point
to the renamed repo okieselbach/AutopilotMonitor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:20:27 +02:00
Oliver Kieselbach a7bb7ebf15 feat: Pro requires a tenant contact address - gated at plan entry, never as a runtime lockout
Backend: POST config/{id}/trial now denies with 409 ContactEmailRequired
when no contact address is stored (extracted EvaluateTrialStart verdict,
order test-pinned: consumed/already-Pro win over the contact prompt). The
gate is live at the API even while the trial CTA stays behind its
"coming soon" flag; GA plan assignment stays unblocked by design. The
feature-flags payload gains contactEmailSet (bool only, never the
address - contract test updated).

Web: trial CTA shows a "set a contact address first" note instead of the
start button when the flag flips on; dashboard gets an amber non-blocking
banner for Pro tenants without a contact (admins only, fail-safe: only an
explicit contactEmailSet:false nags, so loading/error/older-backend states
stay quiet); GA tenant editor warns when assigning Pro to a tenant
without a contact address.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:58:49 +02:00
Oliver Kieselbach a7a1cc43c1 fix(backend): welcome mail survives the auto-approve race - send-on-save + once-only marker
First production auto-approve (mueller-steinag.ch) activated 64s after signup,
5s before the user saved their notification address on the activation page -
the approval path found no address and silently skipped the welcome mail.

Both racing paths now write-then-read: the notification-email save path checks
approval fresh (bypassing the 30s negative cache) and triggers the send when
activation already happened. A conditional-insert welcome-email-sent marker in
PreviewWhitelist makes the send once-only across both paths; it is consumed
strictly after the address check so it can never be burned without an address.
Revoke clears the marker, the GA resend endpoint sends unconditionally and
consumes it best-effort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:33:59 +02:00
Oliver Kieselbach d3a10dfb96 fix(backend): tenant activation is race-proof - conditional insert dedupes welcome mail and ops event
Double signup on two instances enqueued two auto-approve envelopes; both
workers could pass the approved-check (stale 30s negative cache) and both
ran the activation side effects - duplicate welcome mail plus duplicate
TenantAutoApproved ops event/Telegram ping. Same duplicate on queue
redelivery after a failed message delete.

The whitelist add is now a conditional INSERT (AddEntity, 409 = already
approved) so storage arbitrates concurrent activations: exactly one caller
sees "newly added" and runs mail/promote/ops event; losers drop quietly.
The GA approve endpoint stays idempotent (200 + alreadyApproved instead of
a duplicate mail on re-approve).

Also fixes a silent-failure path: the repo swallowed storage errors and
returned false, which ApproveAsync ignored - a failed whitelist write
still sent the welcome mail and reported success. Storage errors now
throw (queue path retries, HTTP path 500s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 16:32:13 +02:00
Oliver Kieselbach 80d0b30cba fix(backend): activation-gate review findings - OnboardedBy immutable via PUT, suspension gate reads fresh
Two findings from the re-run public-availability review:

- UpdateTenantConfigurationFunction preserved HomedAppClientId, LastAuth*
  and plan fields against round-trip clobber but not OnboardedBy, despite
  AuthFunction documenting it as write-once: any own-tenant admin PUT could
  overwrite or null it, corrupting audit provenance and letting a
  revoke -> re-approve cycle auto-promote an attacker-chosen UPN. Now
  preserved like the other system-owned fields.

- TenantAutoApproveHandler checked the suspension gate through the cached
  TryGetConfigurationAsync (5-min per-instance TTL): a tenant suspended
  right after signup could still be auto-approved from a stale view. Now
  reads via GetConfigurationFreshAsync; a storage error propagates so the
  message retries (flag re-checked per attempt) instead of silently
  downgrading the tenant to manual approval.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 16:13:31 +02:00
Oliver Kieselbach aee723e497 fix(backend): app-homing flip reads config fresh - stale cache could rewind concurrent writes
FlipAsync read the tenant config through the cached GetConfigurationAsync
(5-min per-instance TTL, fail-open to CreateDefault) and saved the whole
entity back: a flip could silently rewind fields another writer changed
within the TTL, and a transient storage read error would have persisted a
default config over the real row. Now reads via GetConfigurationFreshAsync
(cache-bypassing, errors propagate) and aborts loudly when no config row
exists instead of materializing a default one.

Daily-review finding 2026-07-31.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 16:01:25 +02:00
Oliver Kieselbach b46a8f1571 fix(rules): merge collision guard - stale tenant rule copies no longer crash rule notifications
Tenants seeded in the pre-global-partition era can still carry per-tenant
copies of built-in rules (IsBuiltIn=false debris, e.g. ANALYZE-ID-002 on one
prod tenant). They pass the custom-rule filter and put the same RuleId twice
into the merged list, blowing up ToDictionary(r => r.RuleId) in
AnalyzeOnEnrollmentEndHandler.SafeNotifyRuleChannelsAsync and silently
dropping rule channel notifications for that tenant.

Guard the merge in AnalyzeRuleService + GatherRuleService: a tenant row whose
RuleId collides with a merged global rule is skipped (global wins) with a
warning log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:40:29 +02:00
Oliver Kieselbach 607d6615b0 fix(auth): app-homing flip survives stale full-model config writers
Prod incident 2026-07-31 18:03Z (tenant b54dc1af): the consent-driven homing
flip landed and 600ms later the frontend persisted the validation toggle via
the generic config PUT - round-tripping a config loaded BEFORE the flip. The
GA branch treated the stale null HomedAppClientId as an intentional revert to
legacy, silently undoing the flip (App Insights: AppHomingFlipped 18:03:00.44,
PUT revert warning 18:03:01.03).

- HomedAppClientId is now IMMUTABLE via the generic PUT for ALL callers, GA
  included: a full-model round-trip can never carry flip intent. The only
  writers are the consent auto-flip and POST config/{tenantId}/app-homing
  (which the GA tenant-management panel already uses).
- HandleAuthClientIdTrackingAsync (same failure class, cross-instance): the
  last-seen tracking write now mutates a cache-BYPASSING fresh read via new
  TenantConfigurationService.GetConfigurationFreshAsync instead of blind-saving
  the up-to-5-min-stale cached view - the login right after a flip is exactly
  the one that carries a changed audience and triggers this write.

3 regression tests (fresh-entity mutation preserves a concurrent flip,
fresh-already-current suppresses the write, steady state stays zero-I/O).
Backend xUnit 3767/3767.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:20:19 +02:00
Oliver Kieselbach 0e2d620ad4 feat(auth): self-service app-homing flip via consent flow + GA lever
Re-consent, verify and flip collapse into the existing consent flow: the
consent URL funnels legacy-homed tenants to the primary app registration,
and consent-status/access-check verify admin consent via an app-only token
probe under explicit primary credentials, then auto-flip HomedAppClientId.

- AdminConfiguration.SelfServiceAppHomingEnabled: kill switch (default off,
  cache-bypassing fail-closed read); gates funnel + auto-flip + tenant-admin
  manual flip. GA path deliberately survives the kill switch.
- POST config/{tenantId}/app-homing (TenantAdminOrGA): tenant admins flip to
  primary only, probe-gated; GA gets force + revert. Pure decision helper
  EvaluateManualFlip shared by function and tests.
- GraphTokenService.GetAccessTokenForAppAsync: probe seam with explicit app
  credentials, cache-safe via app-scoped token keys.
- EntraAppRolesEnabled does not block a flip; it emits a Warning ops event
  (AppHomingFlippedWithEntraRoles) reminding to re-assign enterprise-app roles.
- Web: homing badge + "App Registration Homing" panel with confirm dialog in
  GA tenant management (typed homedAppClientId hardens the PUT round-trip),
  funnel/success banners in tenant settings with switchAuthApp re-login,
  admin-config kill-switch round-trip, ops event types dual-registered.
- Browser telemetry (App Insights): pre-init event buffer so module-init auth
  events survive; strategic events across the whole flow (app_homing_flipped,
  consent_flow_*, consent_verify_failed, auth_app_fallback_*, auth_app_switched,
  auth_app_homing_learned, app_homing_manual_flip*).

Backend must deploy before web (additive JSON fields); everything is inert
while EntraId:LegacyClientId is unset. Enable the kill switch in the admin
portal after the dual app-reg config swap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 19:52:23 +02:00
Oliver Kieselbach 69d61c6c26 feat(backend): dynamic poison-queue enumeration for health + ops monitoring
The static MonitoredPoisonQueues watch-list had already drifted twice
(tenant-auto-approve-poison, session-deletion-maintenance-poison missing).
Health check and maintenance watcher now enumerate every existing *-poison
queue in the storage account at runtime; an absent queue has never had a
poison-move and is healthy by definition, so enumeration covers everything
a list would. Enumeration failure surfaces as warning (never a silent
all-clear) on the health card and skips the maintenance tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 18:59:21 +02:00
Oliver Kieselbach f795fbcb4e feat(auth): dual app-reg parallel operation - legacy gktatooine app alongside new C4A8 app
True parallel operation, no forced re-consent: existing tenants keep signing
in via the legacy app (1a400946), new tenants land on the C4A8 app (886ab5e2).

Model "default legacy, funnel primary, learn in the background":
- Web: am_auth_app flag -> NEXT_PUBLIC_ENTRA_DEFAULT_APP -> primary; get-started
  CTA hands over with ?authapp=primary; auth/me homedApp updates the flag so an
  operator tenant flip takes effect on each user's NEXT login; AADSTS90094/65001
  one-shot cross-app retry, AADSTS65004 -> failed screen with previous-app link;
  am_login_attempt pins redirect returns to the app that started them.
- Backend: TenantConfiguration.HomedAppClientId (null = legacy, GA-only flip via
  PUT config) + LastAuthClientId/Since observability; EntraAppRegistry resolves
  the acting app per tenant (no-op until the legacy config pair is set); Graph
  client-credentials, admin-consent URLs and the surfaced client id follow the
  homed app (app-scoped cache keys); JwtBearer [Authorize] pipeline now shares
  the middleware audience builders (was single-audience - would 401 legacy
  tokens after the swap); middleware trusts EntraId:LegacyClientId implicitly.
- well-known association file now lists both app ids (publisher domain);
  deploy-web passes NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID / _DEFAULT_APP;
  mcp-server.bicep default client id -> C4A8 app.

Backend xUnit 3730/3730, vitest 625/625 (13 new), tsc/ESLint clean, next build ok.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 18:12:25 +02:00
Oliver Kieselbach 6bd5728652 feat(platform): public availability — tenant activation replaces Private Preview, optional auto-approve
Backend: shared TenantApprovalService (whitelist add + auto-promote shape-guard +
welcome email), AdminConfiguration.AutoApproveNewTenants (default off, uncached
fail-closed accessor), tenant-auto-approve queue (1-min visibility delay, worker
drops on flag-off/approved/suspended/missing config, approves as "System
(auto-approve)" + TenantAutoApproved ops event), 30s negative whitelist cache,
gate 2 error PendingActivation with mode-neutral message, signup strings and
welcome email without preview wording.

Web: /preview → /activation (SWA 301, robots, AuthGate), AuthContext accepts both
error codes and clears pending state on successful auth/me, activation page polls
every 30s and auto-redirects into the portal, dashboard banner reframed to
"Actively developed" + feedback call, terms/privacy/get-started updated, feature
badges → Beta, admin-config web chain + auto-approve checkbox, OPS_EVENT_TYPES.

Deliberately unchanged: PreviewWhitelist/PreviewConfig tables, internal preview/*
API routes, preview_signup notification type, Microsoft DevPrep preview mentions.

Verified: solution build 0 errors, xUnit 3701/3701, tsc 0 errors, vitest 612/612,
ESLint ratchet holds (0/115), next build exports /activation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:30:57 +02:00
Oliver Kieselbach a70c5de667 feat(plan): rename Enterprise plan to Pro; Pro includes Bootstrap by default and Unrestricted Mode on request
- FeatureEntitlementCatalog: TenantEdition.Pro, canonical tier "pro", legacy
  stored "enterprise" stays readable as Pro (no data migration); new
  entitlements BootstrapIncluded + UnrestrictedModeAvailable
- TenantEntitlementService: IsBootstrapEnabled (Pro-included OR additive GA
  flag) and IsUnrestrictedModeActive (Pro AND GA gate AND tenant toggle,
  read-time - trial expiry/downgrade re-arms guardrails fail-closed)
- All bootstrap endpoint gates + SecurityValidator use the effective check;
  /auth/me and feature-flags expose effective availability values
- PlanManagement accepts community|pro; AlreadyPro; Pro trial wording
- Web: edition.ts accepts legacy "enterprise" (deploy-order safety), Pro
  labels/cards + new Pro feature bullets, effective section gating,
  terms/privacy renamed with Last-updated bump
- Tests: catalog matrix, effective-gate payload tests, helper tests incl.
  trial-expiry re-gate (backend 3692 green, web 612 green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 13:04:47 +02:00
Oliver Kieselbach e4c06a9564 Landing page v2 — scroll story, portal-first hero, Autopilot Monitor rebrand (#113)
* feat(web): landing page v2 — scroll story, green brand, Autopilot Monitor wordmark

Complete rebuild of the landing page as a scroll story of one enrollment:
- Hero shows the product live (animated event-stream timeline, real event vocabulary)
- Story acts: 09:02 power-on -> 09:41 ANALYZE-ESP-001 hit (most-firing rule, ~22% hit rate)
  -> 09:43 MCP terminal demo (full session report via get_session_summary +
  get_time_attribution) -> fleet zoom-out
- Floating pill navbar, capabilities strip, restyled comparison, shared SiteFooter
  (now also mounted on /about, /terms, /privacy which had no footer at all)
- Green mark (#33B161) unified as the only brand mark via BrandMark.tsx;
  blue-indigo gradient marketing mark removed
- Wordmark is now "Autopilot Monitor" (two words) across all user-visible text,
  metadata and JSON-LD; technical identifiers (script names, ProgramData paths) untouched
- Theming via lp-* CSS variables on :root/.dark — new components never collide with
  the global .dark !important overrides; old .landing-page block kept for /preview
- StatsBand replaces PlatformStats: adds success rate (successfulEnrollments payload
  field, previously unused) and a loading skeleton instead of fake defaults
- New opengraph/twitter/apple images in the new design (green mark on dark slate)
- All animations hand-rolled (rAF/CSS/IntersectionObserver), CSP-safe, zero new
  dependencies, prefers-reduced-motion respected; auth path (AuthGate/LoginButton/
  hostRouting) and staticwebapp.config.json unchanged

Checks: tsc clean, vitest 611/611, static export build clean, no horizontal
overflow at 375px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): landing feedback round — linear story timeline, honest examples, dev stats

- Story is now one vertical timeline with a strict top-down reading order
  (time rail left, text left, visual right) instead of the zigzag grid;
  act 1 gets a "session appeared" card so no column is left empty
- Hero live session decoupled from the story device (CONTOSO-0912/Surface
  Laptop 7 completes; the story's CONTOSO-4711 is the one that gets stuck)
- Defender for Endpoint removed from the demo stream (it is onboarded, not
  installed during ESP) — replaced with Microsoft 365 Apps
- StatsBand: sample numbers in local dev only; in production the band shows
  real figures or hides entirely on fetch failure (never fake numbers,
  never an endless skeleton)
- Capabilities copy: "Pull a diagnostics package" instead of the incorrect
  "Pull ETL and event logs"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v3 — mission-control cockpit instead of single-session card

The first screen now shows the product as a live fleet cockpit: KPI strip
(active-now count ticks), five enrollments running concurrently with
advancing progress bars, a new session popping in, two completing during
the ~23s loop, and the analysis engine visibly flagging the stuck device
inline (HIGH ANALYZE-ESP-001 with the culprit app). Event stream column on
desktop. Headline block is left-aligned and more compact to break the
centered-template look. Deterministic single-tick choreography,
IntersectionObserver start, prefers-reduced-motion gets a rich static
frame. Replaces LiveTimeline.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v4 — real portal screenshots instead of fabricated cockpit

Hero visual is now the actual product: the live portal dashboard (captured
via Playwright from portal.autopilotmonitor.com, demo fleet staged in the
DOM — no customer data, preview banner removed, wordmark as it ships) with
a real analysis finding card (ANALYZE-APP-002 incl. remediation + evidence
JSON, from the docs assets) overlapping bottom-right. Bonus asset
time-attribution.png captured for later use. Removes HeroCockpit.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v5 — clean dashboard, real-analysis terminal, refined header

- Analysis-finding overlay removed: the dashboard shot stands alone (was
  colliding with the stats band and not catching)
- Terminal demo moved into the hero as its own "Then just ask" section and
  rescripted from a real production analysis (anonymized): failed attempt
  with 0x8007EA61 decoded to unmapped installer exit code 60001 (PSADT),
  transient proof via clean retry, actionable recommendation
- Headline block refined: uppercase eyebrow instead of pill badge, tighter
  type and buttons
- Wordmark "Monitor" back to ink color (green variant parked for compare)
- Story act 3 now shows the real time-attribution screenshot instead of
  duplicating the terminal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): story act 3 — notification + one-click diagnostics instead of cropped screenshot

"See where the minutes went" (half-cut time-attribution raster) replaced
with the admin's real next move: the Teams/webhook alert that fired with
the rule, and the on-demand diagnostics flow (requested -> collected
mid-enrollment -> ZIP ready). Crafted JSX cards consistent with the other
acts; unused time-attribution.png removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v6 — portal-first layout from approved design draft

- Full-width sticky navbar (brand, Product/Capabilities/Compare/Docs,
  Sign in + Get started) replaces the floating pill
- Hero per draft: gray eyebrow, "See every Autopilot enrollment. Live.",
  sub left / CTA group right with trust line, portal shot nearly full
  width (max-w-7xl), green background glow removed
- Stats as a full-bleed band with dividers under the shot; "issues
  detected" replaces success rate (underlines automated analysis),
  events compact-formatted (8.3M)
- "Then just ask" terminal extracted into its own JustAsk section after
  the band

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): get-started flow page, honest Teams card, calm buttons, light-only landing

- New public /get-started page: five-step schematic (sign in -> request
  access -> consent -> deploy bootstrapper -> watch live); "Get started"
  in navbar and hero now lead here, "Sign in" keeps the login handoff —
  resolving the two-identical-CTAs problem. Wired into hostRouting
  PUBLIC_PATH_PREFIXES, sitemap, robots allow-list.
- StoryAction Teams card corrected against the backend renderer: the real
  adaptive card has exactly one action ("Open session") — the invented
  "Collect diagnostics" button is gone (NotificationAlertBuilder.cs:60).
- Buttons no longer jump (translate-y removed); hover is brightness +
  shadow only. Final CTA rebuilt as a calm two-column band per draft
  (no dark box, no glow).
- Landing/marketing surface is light-only for now: lp-* tokens re-declared
  under .dark .landing-v2, theme toggle removed from LandingNavbar
  (screenshots are light; portal keeps full dark support).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): about/terms/privacy on the landing shell — no more sidebar flash

The three public pages now render exactly like /get-started: LandingNavbar
+ landing-v2 tokens + eyebrow/headline header + SiteFooter. GlobalSidebar
no longer treats them as sidebar pages for anonymous visitors — that was
the "weird loading": app sidebar + PublicSiteNavbar popping in after
hydration on marketing pages. PublicSiteNavbar and PublicPageHeader are
dead and deleted; terms/privacy links switched from indigo to the accent
green. Authenticated users keep the portal chrome on these pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): landing navbar anchors root-anchored so they work from subpages

#story/#features/#comparison -> /#story/... — clicking Product/Capabilities/
Compare on /get-started (or any public subpage) now navigates back to the
landing section instead of dead-ending on a missing local anchor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): calmer CTA green, about de-blued, public-page typography aligned

- All real CTAs (navbar, hero, final CTA, get-started, story mock button)
  switch from bright #33b161 to the deep accent-ink green — matches the
  dark-green family in the portal screenshot, less loud
- About page fully on the new design language: rainbow colorMap collapsed
  to the single green accent, audience cards/step bubbles/links/quick-link
  hovers de-blued
- About/terms/privacy typography scaled to landing sizes: 15px body,
  smaller page h1s and section headings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): terms callouts on lp tokens — no more stray blue/amber banners

Info callout ("Who can use it") now uses the green accent-soft style;
the AS-IS warning uses the palette warn tones. Verified terms/about carry
no remaining raw blue/amber/indigo utility colors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): about tech stack — drop version numbers, fix stale SSR claim

Versions age silently on marketing pages (.NET 10 / Next.js 15 / React 18
removed, technologies stay). Also corrects "server-rendered React app" —
the portal has been a static export since the RSC-prefetch incident fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): terminal demo app renamed to Contoso CRM App

Neutral fictional app instead of a vendor-adjacent print driver name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): analysis card confidence score, 40+ rules claim, final CTA to /get-started

- StoryAnalysis card gets the portal-style confidence bar (80%)
- Act 2 copy: "40+ built-in rules" (exactly 40 in rules/analyze today)
- "Start monitoring now" leads to /get-started like the other Get-started
  CTAs; sign-in stays in the navbar and on /get-started itself

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal brand aligned to landing, subtle Enterprise anchoring

- Portal navbar (both variants) now uses BrandMark + the landing wordmark
  style (w-6 mark, 15px bold tracking-tight, strong ink in light/dark) —
  completes the brand consolidation follow-up
- Enterprise plan anchored softly, free-first tone preserved: final-CTA
  subline + /get-started closing note ("same service, nothing to
  migrate") + "Plans" link in the footer Product column, all pointing to
  the docs plans page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): final CTA right-aligned, get-started enterprise note "and more"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): social images refreshed to current hero claim and tagline

"See every Autopilot enrollment. Live." + the site tagline (real-time
monitoring, automated analysis, AI-ready telemetry) on the green-mark
dark card; opengraph + twitter regenerated at 1200x630.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): social card wordmark single-color, matching the site

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal sidebar selection states green — brand-aligned like the docs

Active nav items, expandable group actives, page-section title/divider
switch from blue to the green brand accent (light + dark variants).
Purple Global Admin mode stays purple (semantic distinction); broader
portal blues (status pills, buttons, KPI tiles) deliberately untouched —
that is a separate re-theme pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): preview feedback round — dark-mode leak, alignment, renamed repo links

- About/terms/privacy no longer render half dark for dark-mode visitors:
  the light-only marketing scope now neutralizes the global .dark utility
  overrides (bg-white / text-gray-* / border-gray-* / gray hovers) inside
  .landing-v2
- All landing sections aligned to one container width (max-w-7xl): story,
  just-ask, capabilities, comparison, how-it-works now share the same
  left edge as hero, stats band and final CTA
- GitHub links updated to the renamed repository okieselbach/AutopilotMonitor
  (old hyphenated URLs redirected, now correct at the source)
- /get-started wording: "Preview" instead of "Private Preview" (legal pages
  and portal status texts intentionally unchanged)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): preview page on the new design + ?demo=1 review mode; footer width aligned

- /preview rebuilt on lp tokens: BrandMark, single wordmark, calm card,
  accent notify box, neutral next-steps, ghost actions — "Preview"
  wording instead of "Private Preview"
- ?demo=1 skips the auth redirects and renders placeholder identity so
  the page can be reviewed unauthenticated (locally and on SWA previews)
- SiteFooter container widened to max-w-7xl to match navbar and sections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): preview page — prominent approval-pending badge, honest status copy

- Amber "Approval pending" status pill above the headline
- Next-steps wording per review ("sign out and wait a bit or contact
  me…", capacity clause dropped)
- Corrected the false "approval status on your dashboard" claim: a
  blocked tenant never reaches a dashboard — signing in again lands back
  on this page while pending, and goes straight into the portal once
  approved (verified against AuthContext PrivatePreview handling)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): about/terms/privacy cards flattened to the landing pinstripe style

All raised cards (shadow / shadow-sm) become flat lp-surface panels with
the thin lp-line-soft border; inner plan boxes and quick-link tiles
switch from gray-200 borders to the same pinstripe line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal primary actions green — buttons, avatar, Progress Portal icon

- Primary buttons (bg-blue-600/indigo-600 + hover pairs) switch to
  green-600/700: admin backups, customs archive, vulnerability data,
  session reports, and the settings SaveResetBar
- Navbar user avatar circles green instead of blue (all four variants)
- Progress Portal sidebar MonitorIcon gets a deliberate green accent
  (light + dark) instead of inheriting gray
- Semantic blues (status pills, info banners, KPI tiles) untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal links and focus rings on brand green

- All focus states unified: focus:ring/border blue-500 and indigo-500
  (207 occurrences) -> green-500 across inputs, selects, buttons
- Link patterns text-blue-600/indigo-600 with hover 800, hover:underline
  variants (36) -> text-green-700 hover:text-green-800; icon hovers
  hover:text-blue-600 (12) -> green
- New dark override .dark .text-green-700 -> green-400, mirroring the
  existing blue-700 convention
- Semantic blues untouched: info banners, status pills, KPI tiles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): local dev sign-in repaired; progress portal brand accents green

Local sign-in appeared broken (always landed on /progress as a role-less
user) — two stacked regressions in the dev API proxy:
1. trailingSlash:true (static-export change) made next dev 308-redirect
   /api/* to /api/*/ BEFORE the proxy rewrite; the Functions backend
   rejects trailing-slash routes, so /me silently failed.
   -> skipTrailingSlashRedirect: true (no effect on the static export;
   SWA handles trailing slashes itself).
2. .env.local DEV_API_PROXY_TARGET still pointed at the pre-EU-cutover
   autopilotmonitor-api (dead since 07-18) -> now -api-eu; proxy verified
   healthy (v1.5.1027).

Progress portal: header icon, icon circle, "Change device" link and
"Check Status" button on brand green; the blue activity indicators
(downloading/installing) stay blue — that is the portal-wide in-progress
semantic, not branding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): loading screens and remaining primary buttons on brand green

- All four loading spinners (AuthGate, ProtectedRoute, CalculatingCard,
  UnmappedSoftwareTab) green instead of blue
- Blue-indigo gradient loading/error backgrounds (AuthGate overlay,
  ProtectedRoute states, error page, CalculatingCard, SectionPlatformUsage)
  replaced with the neutral lp background (theme-aware in the portal)
- 19 non-contiguous primary buttons (bg-blue-600 text-white ...
  hover:bg-blue-700) the first sweep missed -> green, incl. ProtectedRoute
  retry/back buttons, error page, audit, diagnosis, session table

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): last blue interactive states green; monitoring headers wrap on mobile

- Time-range toggles (fleet health, software, usage, dashboard, platform
  usage), software lens tabs, MCP-usage segmented controls, SLA report
  button, gather/analyze rule save buttons, and the page-header icons on
  fleet health / geographic performance -> brand green. Zero remaining
  bg-blue-600/indigo-600 interactive patterns.
- Monitoring page headers (fleet health, software, SLA, usage metrics,
  geographic performance, audit): title row gets flex-wrap + gap-y-3 and
  the control group (tenant scope + range buttons) wraps as a compact
  second line on narrow screens instead of pushing off the right edge;
  range buttons slightly compacted (px-3 py-1.5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): geo map dev crash — per-mount container key for Leaflet

"Map container is already initialized": dev-mode remounts (StrictMode
re-runs, Fast Refresh over the dynamic() wrapper) hand Leaflet the same
container div twice and it refuses. A per-mount key on MapContainer
forces a virgin div for every (re)mount; MapCleanup keeps handling
instance teardown. No production behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): form control accents (radios/checkboxes) green portal-wide

Checked-state colors for radios and checkboxes (indigo/blue/amber/teal/
sky variants, 19 controls incl. the diagnostics destination radios and
subfolder checkboxes) unified on green, plus the diagnostics "Add" button.
Deliberately kept: red danger-confirmation checkboxes and the purple
global-admin control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): pin native form-control accent-color to brand green

Root cause of the "red" radios/checkboxes: there is no @tailwindcss/forms
plugin, so text-* utilities never styled native controls — their check
color silently followed the user's OS accent color. A global accent-color
rule (green-600, green-500 in dark) pins all radios/checkboxes to the
brand; the danger-red and global-admin-purple controls override via
accent-* utilities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): progress portal icon inherits item color again

Permanent green stood out against the gray siblings — the sidebar icon
now follows the item state like all others: gray at rest, green when
active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): geo map root fix (react-leaflet 5) + readable dark muted text

- Root cause of "Map container is already initialized": Next 15 app
  router runs its bundled React 19 regardless of the installed react@18,
  and react-leaflet 4.x is incompatible with React 19 StrictMode
  double-invoked effects. Upgraded react-leaflet 4.2.1 -> 5.0.0
  (@react-leaflet/core 3) and removed our now-counterproductive
  workarounds (MapCleanup manual remove, per-mount container key) — v5
  owns the map lifecycle including unmount teardown. Verified via a
  throwaway repro page: map renders, remount toggle survives, console
  clean (repro page deleted).
- Dark mode: .dark .text-gray-400/500 lightened (#64748b -> #7b8ba3 /
  #8896ad) — notification timestamps and other muted text were nearly
  invisible on raised dark surfaces (unread bg, hover rows).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): subtler dark hover surfaces — hovered rows stay readable

.dark hover:bg-gray-50/100 dropped from #334155 to #283548/#2e3c52 (one
step above the card surface): the bright hover wash made notification
timestamps and other muted text illegible on highlighted rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): GA/tenant notification rows dark-aware — opacity variants escaped the overrides

The global-admin and tenant notification rows use bg-purple-50/30 and
bg-blue-50/30 (+ /50 hovers) — opacity-variant class tokens the global
.dark .bg-*-50 overrides never match, leaving light rows with light text
on dark mode. Explicit dark: variants (purple/blue-900 at low opacity)
fix the readability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): notification row hover clearly distinguishable in dark mode

GA (purple) and tenant (blue) rows: dark hover raised from /30 to /45
opacity so the highlighted row visibly differs from its resting tint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): one SegmentedControl for all range/group toggles

New shared components/SegmentedControl.tsx (the geo-style connected
pills on a gray track) replaces the three mixed patterns: loose button
trios on fleet health and software, the months <select> on SLA, and
geo's two inline toggles (time range + group-by) now all render the
same control with shared 7/30/90-day labels. Dark mode: active pill
uses arbitrary-value colors because the global .dark .bg-white override
would blend it into the track.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): MCP usage sections use the shared SegmentedControl too

The last remaining variant (joined bordered buttons with raw 7d/30d/90d
labels in admin metrics + reporting MCP usage) replaced by the shared
control with the unified "7/30/90 Days" labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: clickable notification deep links in the bell dropdown

GA notifications now carry hrefs (session/diag-files report -> report
modal, preview signup -> tenant management filtered), GA rows render the
View link, and every bell row with an href navigates on row click and
closes the dropdown. Target sections accept ?reportId= / ?tenantId= via
the proven Suspense+useSearchParams pattern from DeviceBlockSection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): npm overrides pin react-leaflet peer deps to installed react

react-leaflet 5 peer-depends on react 19; the app router runs Next's
bundled React 19 at runtime while the installed package stays react 18.
Without the overrides a plain npm ci (SWA/Oryx build) fails on the peer
conflict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:21:18 +02:00
Oliver Kieselbach d0d1d28371 perf(backend): column projection for unmatched-software scan
GetUnmatchedSoftwareFunction runs a full cross-tenant scan of
SoftwareInventory on every request (including every pagination click) but
only reads 9 properties. GetAllSoftwareInventoryAsync now accepts an
optional $select projection and the function passes the consumed columns,
cutting transfer and entity materialization.

Server-side filtering on CpeUri is documented as impossible: rows
re-created by the cascade-delete 404 path carry no CpeUri property and
Table Storage cannot express "property missing" — those rows must still
count as unmatched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:22:07 +02:00
Oliver Kieselbach 89c5c7da03 feat: richer, self-describing session report content
The report ZIP now captures everything an operator needs even after the
session is gone, and states its own completeness:

- session.csv: append the 15 newer Sessions fields that had drifted out
  of the export (failureSource, reconcileReason, adminMarkedAction,
  validatedBy, rebootCount, isHybridJoin, isSelfDeployingProfile,
  connectionType, geo*, avgApiLatencyMs, apiRequestCount, stalledAt,
  failureSnapshotJson)
- completeness signal: exportedEventCount / sessionEventCount /
  eventStreamActive in the request and report-metadata.json; the modal
  warns while event pages are still streaming
- includedDiagnostics records sourceBlobName/sourceDestination so a
  failed copy still points at the original archive
- tenantContext snapshot (fail-soft): upload mode/destination, gather-
  rule debug flag, custom gather/analyze rule IDs + UpdatedAt, IME
  pattern IDs — no secrets, no definitions
- robustness: 8 MB client-side screenshot cap (20 MB body cap headroom),
  attachments status in metadata instead of silently dropping invalid
  base64, SanitizeZipEntryName strips path components from customer-
  supplied file names (both report types)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:13:33 +02:00
Oliver Kieselbach 48308df4ff feat: opt-in preservation of session diagnostics archive with session reports
Session reports persist while sessions and their diagnostics blobs are
removed by user deletion or retention. A new checkbox in the Report
Session modal (shown only when a diagnostics ZIP was uploaded, default
checked) lets the submitter include the archive: the backend streams a
server-side copy into the durable session-reports container.

- SessionReportDiagnosticsArchiveCopier: fail-soft copy (never blocks the
  report), reuses the Hosted/CustomerSas read paths, re-verifies the
  hosted tenant prefix via ClassifyBlobName, honours the admin size cap
  and download timeout; outcome persisted as DiagnosticsCopyStatus
- Report row/API gain DiagnosticsBlobName + DiagnosticsCopyStatus
  (written only when requested, omitted for legacy rows)
- report-metadata.json records includedDiagnostics {requested, blobName, status}
- Admin reports UI: second Download Diagnostics button + failure status line
- Privacy page: explicit exception for the opt-in report copy (Last updated bump)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 13:53:12 +02:00
Oliver Kieselbach f744ad8406 fix code review findings 2026-07-29 10:30:47 +02:00
Oliver Kieselbach ffe88a47c6 fix: /sla dead-ended on www — it is a portal page, not a public one
/sla sat in PUBLIC_PATH_PREFIXES only because SLA notifications
historically linked www…/sla. It is a fully authenticated page
(ProtectedRoute + token fetches), and since ProtectedRoute stands down on
the public host, www/sla waited forever on "Loading SLA metrics...".

Removed from the public prefixes: the nav stays on portal (no bounce),
old www…/sla notification links bounce to portal/sla where auth works,
and SlaBreachEvaluationService.DashboardUrl now links the portal host
directly for future notifications. /sla added to robots disallow.

Web tsc + 603/603, backend build clean + SLA suite 38/38.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:39:56 +02:00
Oliver Kieselbach 18b28260a7 feat: portal static export — eliminate the SWA SSR runtime (cold-start root fix)
output:'export' + trailingSlash: the SWA now serves plain static files; the
managed SSR runtime — the portal's last cold-start source ("Backend call
failure" when a cold start exceeded SWA's 45s limit) — no longer exists.

- Detail routes are query-string based (/sessions?id=, /sessions/inspector?id=,
  /diagnosis?id=, /apps/detail?name=, /admin/backups/detail?id=,
  /admin/customs-archive/detail?tenantId=&rowKey=). Single-definition builders
  in lib/routes.ts; all 21 link sites migrated, duplicate sessionHref helpers
  consolidated. Suspense boundaries added everywhere useSearchParams drives
  page identity (hard export-build errors otherwise).
- 9 [section] families prerender via server shells (generateStaticParams from
  the *NavSections registries, dynamicParams=false, client bodies extracted to
  SectionClient) — 37 pages; server redirect() index pages became
  ClientRedirect shells. robots/sitemap/og-image routes force-static.
- Legacy PATH-shaped deep links are preserved FOREVER (sent Teams/Slack/
  webhook notifications cannot be edited): SWA wildcard rewrites serve the
  family page and the globally mounted LegacyPathRedirect rewrites to the
  canonical query URL, preserving query + #event hashes (mapping pinned by
  lib/__tests__/legacyPathRedirect.test.ts).
- middleware.ts deleted → client-side HostRoutingGuard (prefix list moved to
  lib/hostRouting.ts). Legacy /go/[code] route + bootstrapValidation deleted —
  BootstrapSessions table verified free of active pre-cutover sessions.
- staticwebapp.config.json now carries production routing: 19 docs/marketing
  redirects (order-sensitive), legacy rewrites (exact before wildcard), 404
  override, all security headers incl. literalized prod CSP (no unsafe-eval),
  Content-Type routes for the extension-less og images. Guarded by the new
  utils/__tests__/swaConfig.guard.test.ts. Maintenance mode is now a
  first-routes-entry insert, never a whole-file swap (README rewritten).
- Backend notification deep links go through Constants.PortalSessionUrl
  (query shape, escaped, pinned); 3 producers migrated.
- deploy-web.yml uploads out/ with is_static_export.

Export build: 79 static pages, no go/ output. Web: tsc clean, vitest 53
files green (618 incl. new guards; 2 deleted legacy suites). Backend:
solution build clean, 3622/3622 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:44:32 +02:00
Oliver Kieselbach 6cbd507b4f fix: client-IP extraction was dead on Flex — RemoteIpAddress fallback + AFD-aware rate-limit key
Post-deploy verification of the go endpoint showed every anonymous caller
keyed as "unknown": Flex Consumption does not surface X-Forwarded-For to
the isolated worker, so ALL per-IP rate-limit buckets (bootstrap-validate,
distress, diag-ticket-download, stats/platform) had silently collapsed
into one shared bucket — confirmed by DistressReports.SourceIp being
"unknown" across the board.

- GetTrustedClientIp falls back to HttpContext.Connection.RemoteIpAddress
  (transport-derived, unspoofable; IPv4-mapped addresses normalized).
  Heals every existing caller without signature changes.
- New GetRateLimitClientIp for AFD-fronted endpoints: honors
  X-Azure-ClientIP only when X-Azure-FDID carries exactly our profile ID
  (FrontDoorProfileId app setting, set on autopilotmonitor-api-eu);
  fail-closed to GetTrustedClientIp on unset/mismatch/multi-value.
  Used by GetBootstrapScriptFunction (traffic arrives via
  go.autopilotmonitor.com, where the trusted hop is AFD egress).
- FdidMatches table tests incl. append/spoof cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 00:01:31 +02:00
Oliver Kieselbach 26bcab9054 feat: move OOBE bootstrap script endpoint into the backend (go.autopilotmonitor.com)
The portal's Next.js /go/[code] route was the web app's only request-time
server code and blocked the planned static export. The script generation
now lives in the Function App as GET /api/bootstrap/go/{code}
(GetBootstrapScriptFunction), reached by customers as
irm https://go.autopilotmonitor.com/{code} | iex via a Front Door custom
domain whose /* route rewrites onto the API path.

- New Constants.BootstrapGoBaseUrl; CreateBootstrapSessionFunction now
  hands out the go URL (previously unguarded — CreateBootstrapSessionUrlShapeTests
  pins it now). go host added to both URL-guard enforced lists.
- BootstrapScriptValueValidator ports the bootstrapValidation.ts injection
  invariants (exact GUIDs, https + host allowlist + /agent/*.zip path,
  expiry <= 14d) as defense-in-depth for in-process values.
- OobeBootstrapScriptGenerator ports the PS template as a Replace-token
  raw string; golden test pins byte parity with the TS template
  (TestData/oobe-bootstrap-golden.ps1, captured from route.ts).
- Always-HTTP-200 text/plain contract incl. rate limit (own bucket
  bootstrap-script:{ip} 20/min); disabled tenant indistinguishable from
  unknown code. Catalog: PublicAnonymous; NoStoreCacheMiddleware covers
  /api/bootstrap/go/ (script inlines the bearer token).
- Web: BOOTSTRAP_GO_URL registry entry, sessions list shows the go host,
  legacy route marked LEGACY (alive for pre-migration URLs <= 168h,
  removal in the static-export PR).
- Docs: new OKF backend/oobe-bootstrap-go-flow.md, url-registry go
  section, index/log entries.

Backend tests 3611/3611, web vitest 651/651, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:42:56 +02:00
Oliver Kieselbach 4ebd60fb1e feat: EnableGatherRuleDebugLog — per-tenant gather-rule debug trace (ConfigVersion 36)
Customers cannot tell why a gather rule produces nothing in the timeline
(e.g. a logparser rule with emit_mode on_change that never emits). New
per-tenant boolean EnableGatherRuleDebugLog (EnableImeMatchLog-style
plumbing end to end): when enabled, the agent writes
%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log with one
pipe-delimited line per evaluation decision across all previously silent
"produced nothing" exits — disabled rules, interval without
intervalSeconds, first-tick-after-one-full-interval, phase-scope skips
incl. currentPhase=Unknown, per-(rule, phase) dedup, empty collector
results (emitOnlyIfExists), on_change hash suppression (hash prefix +
streak), guard blocks, logparser per-file outcomes (position skips,
format=text hint when every line fails CMTrace parsing, maxLines and
wildcard caps), and full exception stacks the agent log drops.

- Backend: ConfigVersion 35 -> 36; TenantConfiguration/AgentConfigResponse
  field + TableConfigRepository Store+Map
- Agent: GatherRuleDebugLog writer (10 MB cap, one .old generation,
  failures swallowed, optional console echo), GatherRuleContext.DebugLog
  (null-safe no-op), trace points in executor + collectors; never writes
  under _scopeLock (reasons captured inside, written after release);
  merger bool->path projection with --gather-debug-log CLI override
  tolerance; deliberately NOT in the offline-cache strip lists;
  --run-gather-rules always traces; zero-rules delivery leaves a one-line
  file instead of nothing
- Side fix: GatherRuleExecutorHost now passes the logger into
  InformationalEventPost so gather-event emissions hit the DEBUG log
- Web: tenant settings toggle (Settings -> Agent) + admin tenant-config
  report rows
- Tests: merger on/off/override, writer format+rotation, executor debug
  lines, logparser debug lines (agent 2131, backend 3561, tsc green)
- Docs: docs/agent/gather-rule-debug-log.md + index/log entries

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:59:30 +02:00
Oliver Kieselbach dff1532be5 feat: project ConnectionType (WiFi/Ethernet) onto SessionsIndex
The agent already reports connectionType in every network_interface_info
event, but it was only stored as a JSON blob in DeviceSnapshot — answering
"how many machines enroll over Ethernet vs WiFi?" required a slow full-table
scan via deviceProperties. Project it as a typed column instead (template:
the AvgApiLatencyMs chain), making it a cheap indexed one-call query.

- Ingest: TryExtractConnectionType — last network_interface_info emission of
  the batch wins (a device switching media mid-enrollment is relabeled);
  only exact "WiFi"/"Ethernet" accepted, fire-and-forget fail-soft writer
- Storage: UpdateSessionConnectionTypeAsync (Merge on Sessions + index
  mirror, tombstone-safe), BuildSessionIndexEntity superset entry,
  MapToSessionSummary, re-registration preserve in StoreSessionAsync
- Search: connectionType= filter with OData eq push-down on the index,
  client-side backstop on the non-OData paths, fields=connectionType
  projection, pagination fingerprint entry, raw endpoint parity
- MCP: connectionType z.enum param on search_sessions/query_raw_sessions,
  fields doc updated (server redeploy required)
- Web: optional sortable/filterable "Connection" column in the session list
- No backfill: legacy sessions lack the column and are excluded by the
  filter (intended, same semantics as RebootCount); the deviceProperties
  scan path still covers historical data

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:25:17 +02:00
Oliver Kieselbach 3ec716a3ec feat: expose API latency to MCP field projection and tool docs
- SearchSessionsFunction fields whitelist += avgApiLatencyMs,
  apiRequestCount so search_sessions lean sweeps (e.g.
  fields=geoCountry,avgApiLatencyMs,apiRequestCount) can aggregate
  latency without full SessionSummary payloads
- search_sessions tool doc lists the new fields incl. the weighting
  hint; get_geographic_metrics doc names avgApiLatencyMs /
  apiLatencyVsGlobalPct as the backend-region distance metric

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:28:01 +02:00
Oliver Kieselbach ffd70e0383 feat: geographic API-latency view (per-location weighted avg by geo)
Surfaces the persisted per-session AvgApiLatencyMs in the geographic
views so operators can compare regions when deciding on new backend
locations (Asia/US):

- ComputeGeographicMetrics: per-location and global request-weighted
  latency (sum(avg*requests)/sum(requests)) + ApiLatencyVsGlobalPct;
  serves both the tenant and the GA cross-tenant endpoint
- GeoMetricsSessionProjection extended (column-projected scan would
  otherwise drop the new fields); equivalence test pins it
- Drilldown: LocationSessionRow carries the per-session values; sessions
  table + summary tile show them
- geographic-performance page: sortable "API Latency" column with
  absolute color buckets (<250 green .. >=800 red), global benchmark
- PlatformMetricsService: session AvgLatency now exact from cumulative
  counters when present (fallback: mean of interval averages)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:58:53 +02:00
Oliver Kieselbach c18154d76d feat: persist per-session agent-to-backend API latency (AvgApiLatencyMs)
The V2 agent already measures HTTP round-trip latency per request
(NetworkMetrics, cumulative _totalLatencyMs) but only emitted the
per-interval delta average. Operators deciding on new backend regions
(Asia/US) need the session-wide average, aggregatable by GeoCountry.
Measured baseline: Jakarta sessions average ~600-1050 ms client-side vs
94 ms server-side processing — the gap is pure network distance to GWC.

- Agent: agent_metrics_snapshot now carries cumulative net_total_latency_ms
  (total/requests = whole-session average; idempotent by construction)
- Ingest: last matching snapshot of a batch projects AvgApiLatencyMs +
  ApiRequestCount onto the Session row (fire-and-forget, fail-soft,
  last-write-wins; agents predating the field simply never match)
- SessionsIndex: both fields mirrored (merge + BuildSessionIndexEntity
  superset) so list/GA views can aggregate latency per geo
- Web: session detail shows "API Latency" when present

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:44:46 +02:00
Oliver Kieselbach d55b0ff864 test(backend): pin the cross-tenant redaction on metrics/ime-versions
GET metrics/ime-versions is MemberRead and the archive is one global
partition, so every tenant member reads the same platform-wide rows. The only
thing between an ordinary tenant user and another tenant's identifiers was an
inline projection with no test behind it.

There is no HTTP-level harness in this suite - nothing constructs an
HttpRequestData with a RequestContext - so the projection was unreachable from
a test while it lived inside Run. Extracted it to a static
BuildResponsePayload(versions, hasGlobalScope), matching the pattern
GetTpmPssUnsupportedFunction already uses for the same reason. Behaviour is
unchanged: the payload is returned as `object`, and System.Text.Json resolves
an `object` declared type via the runtime type, so both branches keep the wire
format they had inline. A test asserts that rather than trusting it.

10 tests, asserting on serialized JSON in camelCase (Program.cs's naming
policy) because a property dropped from an anonymous type is only a real
redaction once it is absent from the wire:

  - non-global: firstSeenTenantId / firstSeenSessionId absent, and their GUID
    VALUES absent anywhere in the payload (a model rename must not let the
    identifier survive under another key)
  - non-global: exactly {version, firstSeenAt, lastSeenAt, sessionCount}
  - non-global: sessionCount survives - the counts are platform-wide by
    decision, pinned so it cannot drift either way unnoticed
  - global (Admin or Reader): full entry including both identifiers
  - both scopes: array shape, empty archive, row order preserved

Mutation-checked both directions: forcing the global branch fails 3 tests,
dropping sessionCount from the projection fails 2. Full suite 3521 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:33:56 +02:00
Oliver Kieselbach 0efeb0f734 fix(backend): alert-webhook 500 on non-seekable body - bounded read replaces Length check
With ASP.NET Core integration the HttpRequestData body is a
forward-only stream whose Length property throws NotSupportedException
(verified in prod: 500 + System.NotSupportedException on the first
POST). The size cap is now enforced by reading at most cap+1 chars via
ReadBlockAsync instead of asking the stream for its length.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:52:59 +02:00
Oliver Kieselbach 7b975d46b6 feat(backend): Azure Monitor alert webhook - platform alerts join the ops-event bus
New anonymous POST ops/alert-webhook receives Common Alert Schema
notifications from an Action Group webhook receiver and re-emits them
as ops events (category Platform, type AzureMonitorAlert), so Azure
Monitor alerts flow through the existing OpsEventService ->
OpsAlertDispatchService fan-out (Telegram/Teams/Slack + portal
ops-events list) like every internally raised operational event. The
delivery path is independent of the portal: a SWA outage cannot
suppress the notification; the action group's e-mail receiver stays as
the backend-independent fallback.

Security: action-group webhooks cannot send custom headers, so the
shared secret travels as ?secret= and is compared in constant time
(FixedTimeEquals) against the OpsAlertWebhookSecret app setting -
fail-closed while unset. 64 KB payload cap, defensive essentials-only
parsing (only alertRule is mandatory), severity mapped Sev0->Critical,
Sev1->Error, Sev2->Warning, Sev3/4->Info with unknown values falling
back to Info so schema drift can never escalate.

Dual-registered per convention: EndpointAccessPolicyCatalog entry
(PublicAnonymous), OPS_EVENT_TYPES in OpsAlertRulesSection (rule
wiring) and Platform category in OpsEventsSection (badge + filter).

Backend suite 3511 green (incl. new helper tests + catalog
completeness), web tsc + vitest 651 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:39:22 +02:00
Oliver Kieselbach ba7d14b070 fix(insights): post-deploy MCP findings - projection drop, aggregate starvation, duration artifacts
Three defects found by measuring production after the 1.5.1012 deploy:

- AppMetricsProjection dropped TerminalState, so the PR0 skip
  classification never reached metrics/app (totalSkipped stuck at 0).
  Column added; equivalence fixture now populates TerminalState/AppId/
  EspBlocking/AppIdCollision and asserts them (proven: test fails
  without the projection fix).
- BuildTimeAttributionAggregates excluded breakdowns on ANY quality
  flag; 903/1000 carry BlockingSetUnknown, starving fleet stats
  (Clean=12 vs 331 excluded on the largest tenant). New
  TimeAttributionFlagQuality.DurationCriticalFlags mask - only
  ClockSkew/PartialObservation/WGAnchors exclude; blocking-set flags
  keep their measured spans (positive-evidence semantics unchanged).
- HasMeasuredDuration gets an upper plausibility bound (6h = default
  AgentMaxLifetimeMinutes): 490 rows carried back-stamped completions
  (up to 117 days; 60175s app rows in a 2697s session) that dominated
  slowestApps with six-figure averages. Read-side, heals old + new rows.

Backend 3484/3484 green; solution build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:50:55 +02:00
Oliver Kieselbach 7842d121a0 fix(insights): codex review findings - WG pause clamping, late-event healing, stale buckets, date windows, banner count
All six review findings verified against code and real; five fixed here,
the agent-side race lands in the companion agent commit.

[P1] WhiteGlove pause counted as time: ClampToWindows clipped intervals
against the HULL of both WG windows, but the excluded pause lies inside
the hull - a reboot gap or app interval straddling the pause counted the
entire pause (a ~2-day "reboot" in a 30-minute enrollment was possible).
Seconds are now the per-window intersection sum (chronological endpoints
kept); an interval wholly inside the pause makes no claim and no longer
raises ClockSkewDropped. What-if savings are additionally capped at the
session's WallClockSeconds - endpoint deltas can still straddle the
pause, and no removal can save more than the enrollment took.

[P1] Late-arriving events never updated breakdowns: the terminal-seam
compute is one-shot and the sweep only healed missing/version-stale rows.
SessionTimeBreakdown now stores EventCountAtCompute (0 = pre-fix row,
recomputed once); the sweep recomputes on EventCount drift and then also
re-runs ResolveEspBlockingForSessionAsync (newly exposed on
IMetricsRepository) so late esp_config_detected re-emits and app batches
reach both the breakdown and the blocking evidence.

[P2] Stale aggregate buckets: the sweeps now reconcile every partition
they wrote - buckets that existed but were not regenerated (sessions
deleted, class left the window) are deleted via new targeted deletes;
rolling30 rows additionally get a 48h ComputedAt freshness filter at
read time, so a fully quiet tenant stops serving year-old numbers as
"last 30 days".

[P2] Date windows included an extra calendar day: both inclusive range
ends meant days=30 returned 31 keys and days=1 summed yesterday AND
today. InclusiveWindowStart(today, days) = today-(days-1) in both
response builders; the sweep's rolling30 source is filtered to the same
30 calendar days (its query window stays one day wider for backfill).

[P3] Device-history banner counted later sessions as "previous
enrollments" when viewing an older session: selectPriorSessions
(deviceHistoryPrior.ts) anchors on the session's own chain entry (live
sessions: new sessionStartedAt prop), sessionId tie-break mirroring the
server chain sort.

Verified: solution build clean, Backend 3480/3480 (+6: three WG-pause
golden fixtures, window-start facts, RK builder/delete consistency,
EventCountAtCompute roundtrip), Web tsc + vitest 633/633 (+6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:52:39 +02:00
Oliver Kieselbach 1bf37f6761 feat(insights): F3 regression radar - Wilson-gated rule hit-rate drift detection (PR6)
Analyze rules only (gather stats stay per-batch-deduped until Q6 is
fixed - never silently included). Per (tenant, rule) the radar compares
the trailing 7-day hit rate (FireCount/SessionsEvaluated from the daily
RuleStats rows) against the prior 28-day baseline and fires only when
the window has >=5 hit sessions and >=20 evaluated sessions, the rate
is >=2x baseline AND the Wilson 95% intervals are disjoint
(MetricsMath.WilsonInterval/RateIncreaseSeparated, hand-computed test
vectors). Suppressed for deleted rules, rules inside the CreatedAt
grace period (<35d), rules edited inside the window, empty baselines,
and the RuleRegressionRadarDisabled app-setting kill switch.

Alert episodes live as ruleregression|{ruleId} rows in the notification
tracker (third keyspace): AddEntity-409 dedup = one bell + one
RuleFrequencyRegression ops event (category Tenant, Warning, full
numbers, dual-registered in the web OPS_EVENT_TYPES) per episode;
numbers refresh while active without moving FirstNotifiedAt; episodes
re-arm under 1.5x baseline, on fire-stop, or via the tracker's 30d
retention prune (spec-intended reminder). On-fire dimension correlation
(osBuild/model/agentVersion/imeVersion; hit set from a RuleResults
range scan intersected with the window sessions) reports both shares
with the "correlated, not necessarily causal" wording contract or
honestly "no clear dimension concentration". Radar runs in the daily
maintenance pass after the rule-stats aggregation, anchored on
yesterday (whole days only).

Surfaces: regressions[] in the rule-stats response (MCP get_rule_stats
inherits it), tenant bell deep-linking to the rule card, and the
analyze-rules page gains a densified 30-day fire sparkline plus a
regression badge while an episode is active. OKF doc
backend/rule-regression-radar.md + index/log.

Backend 3474/3474 (27 new: Wilson vectors, every suppression branch,
window boundary dates, re-arm matrix, dimension gates, tracker
round-trips incl. tri-states, keyspace dedup/prune, wording pins),
web tsc + vitest 627/627, MCP tsc + 455 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:10:52 +02:00
Oliver Kieselbach 88da317bf4 feat(insights): F2 surfaces - device-history banner, fleet FTR panel, API routes, MCP tool (PR5)
Three read routes over the PR4 data: metrics/device-history
(MemberRead + tenant query param - one route serves the web banner AND
MCP, GA cross-tenant via ?tenantId= with a sessionId-based owner
fallback; history:null is a normal outcome), metrics/device-journeys
and global/metrics/device-journeys (days selector honored because FTR
counts are additive - window rate = ratio of summed daily rows; the
tenant-less global variant serves the "global" partition and discloses
repeatDevices:null). Attempt numbers are server-computed
(DeviceJourneyCalculator.ComputeAttemptNumber: chain position within
the journey; live sessions via a virtual non-successful attempt so the
redeploy and 30d-gap rules apply) - no consumer re-derives journey
semantics. Repeat-devices violator list: last journey >=2 attempts with
newest terminal ref in-window, attempts desc then recency, cap 10,
failure reason from the newest failed attempt (bounded point-reads).

Web: DeviceHistoryBanner on session detail ("Attempt N for this device
- View history", expandable terminal chain, durations strictly the
authoritative durationSeconds, WG/DevPrep/admin-marked badges) and the
Fleet Health "First-time-right" section (rate gated at >=20 completed
journeys with disclosed n, junk-serial exclusion note, Monday-UTC
weekly trend, attempt histogram, repeat devices) following the 7/30/90
selector. MCP: get_device_history (serial mode / fleet FTR mode,
role-aware routing, all roles - GA_FULL privilege snapshot extended
deliberately). Docs: OKF backend/device-journeys.md + index/log.

Backend 3447/3447 (14 new: attempt-number vectors incl. gap boundaries
and live sessions, additive sums, days clamping, repeat-device
gate/sort/cap), web tsc + vitest 627/627, MCP tsc + 455 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 08:38:17 +02:00
Oliver Kieselbach 1e6de47433 feat(insights): F2 journey grouping + DeviceHistories chains + daily FTR aggregates (PR4)
Device-key normalization (trim+casefold, junk-serial exclusion incl. the
agent's "Unknown" WMI sentinel), pure journey grouping (terminal set
Succeeded/Failed/Incomplete; journey ends at first success; >30d gap from
the previous attempt's CompletedAt starts a new journey; WG Pending stays
an open session, never an attempt), and the DeviceHistories table
(PK tenant, RK encoded normalized serial): terminal-only session-ref
chains capped at 20 with derived journey counts, written inline at the
session-terminal seam (all three terminal statuses, DeletionState-guarded)
and healed by a rolling 30d maintenance sweep. The sweep prunes chain refs
of deleted sessions tombstone-driven (SessionTombstones cover cascade AND
retention deletions), deletes emptied rows, and recomputes daily FTR
aggregates (DeviceJourneyAggregates, PK tenant/"global", RK date):
completed journeys, first-time-right count, attempt histogram and
junk-serial disclosure — additive counts, bucketed on the completing
success session's StartedAt date. Chain refs carry the authoritative
DurationSeconds verbatim (never CompletedAt-StartedAt) plus an
adminMarked flag. Both tables registered in Constants.All + offboarding
TenantPartitionTables; aggregates under the 180d retention block;
deliberately NO per-session deletion-manifest step for DeviceHistories.

Backend 3433/3433 (51 new tests: junk/normalization vectors, terminal
set, exact 30d gap boundaries, WG one-row-one-attempt, redeploy split,
deletion pruning, entity round-trips incl. row-key escaping, FTR
aggregation core), Agent 2044+493 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 08:14:13 +02:00
Oliver Kieselbach 6de21e821a feat(insights): F1 surfaces — session lane, fleet panel, API routes, MCP tool (PR3)
Insights expansion PR3 (tasks/insights-expansion-spec.md §F1 "Surfaces").
Completes F1: schema (PR1) -> persistence/aggregation (PR2) -> surfaces.

- Rolling range rows: the sweep now also writes "rolling30|{class}"
  aggregate rows — range statistics over the window's actual sessions. A
  median of per-day medians is not the range median, so the fleet panel
  reads these and is deliberately fixed to "last 30 days"; the daily rows
  stay in the payload for trends/MCP.
- Routes (all registered in EndpointAccessPolicyCatalog):
  GET sessions/{sessionId}/time-attribution (MemberRead + tenant query
  param, GA fallback via FindSessionTenantId; breakdown=null is SUCCESS —
  a missing lane is a normal state, not an error), GET
  metrics/time-attribution (MemberRead) and GET
  global/metrics/time-attribution (GlobalReadOrAdmin + tenantId filter),
  both returning { windowDays, classes (rolling), daily }.
- Web: TimeAttributionLane under the session-detail phase timeline —
  proportional stacked bar of segment totals incl. the explicit
  unattributed remainder (bar always sums to the session's authoritative
  duration), ESP-blocking apps with critical-path occupancy, reboot badge
  (cross-cutting, never a bar slice), quality-flag chips; fetched
  fail-soft for terminal sessions only. Fleet Health gains the "Time
  attribution" section: per-class stacked medians gated at n>=20
  ("insufficient data (n=...)" below), flagged/missing exclusions
  disclosed, and a top-blocking-apps table with class-scoped rows and
  "up to X (p75 Y)" what-if wording. Deliberate deviation: the spec's
  per-app trend column is omitted — per-day app rows are too sparse under
  the daily n>=5 gate; revisit once rolling history exists.
- MCP: get_time_attribution (one tool, session + fleet mode, role-aware
  routing like get_app_install_metrics, registered for all roles) — the
  privilege-leak-guard catalog snapshot extended deliberately.
- Docs: OKF concept doc backend/time-attribution.md (invariant, windows,
  segment mapping, positive-evidence blocking, Q7 reboot spans,
  persistence + surfaces) registered in index.md + log.md.

Backend 3382/3382 (+1 rolling-aggregate test), web tsc clean + vitest
627/627, MCP tsc clean + 455 passed. Post-deploy: web deploy +
MCP redeploy (new tool + docs corpus).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:33:16 +02:00
Oliver Kieselbach 55c898bb9c feat(insights): F1 breakdown persistence + daily attribution aggregates (PR2)
Insights expansion PR2 (tasks/insights-expansion-spec.md §F1 "Data & compute
changes"):

- SessionTimeBreakdowns table (PK=TenantId, RK=SessionId): span/reboot/
  blocking-app JSON columns, QualityFlags, AttributionVersion,
  EspAppsOccupancySeconds kept tri-state (absent column = unknown, never 0).
  Written once at the terminal single-writer seam (TableSessionRepository,
  next to the counter reconcile + EspBlocking resolution; Succeeded/Failed
  only — Incomplete deliberately stores no duration). Deleted with its
  session: new deletion-manifest step 16 (PkRkExact; tombstone moves to 19)
  plus tenant-offboarding partition wipe — no separate age sweep.
- Self-healing 30d maintenance sweep instead of a one-off backfill (rides
  the existing 2h tick + manual parity): backfills pre-deploy sessions,
  heals inline-compute misses, folds late-terminating sessions into their
  StartedAt-date aggregates, and enforces AttributionVersion purity — a
  stale-version breakdown whose events aged out counts as missing rather
  than mixing algorithm semantics (truthfulness rule 8). Skips sessions
  with a deletion cascade in flight.
- TimeAttributionAggregates table (PK=TenantId or "global",
  RK="{date}|{class}"): per enrollment class (whiteglove /
  device_preparation / self_deploying / user_driven — never mixed),
  median/p75/p90 per segment over the fixed six-segment stack (incl.
  unattributed; absent segment = honest 0), clean/flagged/missing counts
  disclosed (rule 7), rows written below the n>=20 UI gate on purpose
  (the UI needs the n — rule 4). 180d retention alongside UsageMetrics.
- What-if bound: WhatIfSavingSeconds implements the spec formula
  max(0, cpEnd - cpEndWithoutX) including the idle gap before X (the ESP
  waits for X to start, too); sole-interval fallback = own start. Per-app
  aggregate rows gate at >=5 sessions, top 20 by median interval, carry
  median/p75 of interval and saving ("up to" wording is the consumer
  contract).

Backend 3381/3381 (13 new tests: entity round-trips incl. tri-state,
aggregation bucketing/disclosure/gates, what-if vectors, class precedence,
manifest step-count pins 17/19), solution build clean, web untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:03:14 +02:00
Oliver Kieselbach e637317bdb feat(insights): F1 schema + time attribution calculator (PR1)
Insights expansion PR1 (tasks/insights-expansion-spec.md §F1, audit §0.5):

- AppInstallSummary + AppId / EspBlocking (bool?, tri-state) / AppIdCollision:
  sentinel-gated columns + mapper + both projections. appId adopted from app
  events (first-seen wins); a second appId under the same display name flags
  the name-keyed row as a collision (audit Q3) — in-batch and cross-batch
  (sticky in the reconcile). Collision rows leave every per-app fleet
  aggregate (app metrics payload, apps list, app analytics) with a disclosed
  exclusion count; the fleet DO rollup and per-app session drill-down keep
  them.
- EspBlocking resolution at the session-terminal single-writer seam
  (TableSessionRepository.UpdateSessionStatusAsync, next to the counter
  reconcile — covers ingest, admin marks, maintenance sweep, rule engine):
  joins the session's app rows against the LATEST list-carrying
  esp_config_detected emission as a positive-evidence set (audit Q2) —
  listed => true, absent => stays unknown, never false. EspBlockingSets
  parser handles truncation via the uncapped espTracked*Count fields.
- TimeAttributionCalculator (pure, AttributionVersion 1) + SessionTimeBreakdown
  models: exact partition of the session's authoritative DurationSeconds into
  device_prep / esp_apps / identity_hello / user_esp / desktop_handoff spans
  plus an explicit unattributed remainder — invariant sum(spans)+unattributed
  == wall clock enforced in every test. WhiteGlove = two observation windows
  in ONE session (part1 anchored on whiteglove_part1_complete, pause never
  attributed). Per-app blocking intervals from EVENT timestamps (first start
  -> LAST terminal, audit Q1 retry freeze), occupancy = merged union clipped
  to esp_apps. Reboot spans from event gaps around lastBootUtc (audit Q7),
  deduped per boot. QualityFlags: ClockSkewDropped, PartialObservation,
  BlockingSetUnknown, BlockingSetTruncated, WhiteGloveAnchorsIncomplete.
  Persistence (SessionTimeBreakdowns) and surfaces follow in PR2/PR3.

Backend 3368/3368 (35 new tests: 19 calculator golden fixtures + 16
identity/blocking/aggregate-exclusion), solution build clean, web untouched
(new response fields are additive).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 17:29:36 +02:00
Oliver Kieselbach 4eac7d042c fix(metrics): app duration/rate stats over real installs only — TerminalState column + out-of-order reconcile
Source-data audit (tasks/insights-expansion-spec.md §0.5) verified against
production: 17.9% of Succeeded AppInstallSummaries rows carry no duration —
78% are genuine skips (app_install_completed with payload state=Skipped,
"Update for X" WinGet policies not applicable) whose state the ingest
discarded, the rest are real installs whose start the agent never observed
(attach window). Averaging them as 0s understated avg install duration by
18% and distorted the slowest-apps ranking.

- AppInstallSummary.TerminalState (Installed/Skipped/Postponed/Error) from
  the closing event's payload state; sentinel-gated column + mapper +
  AppsDashboardProjection. Status stays "Succeeded" for compatibility.
- MetricsMath.IsSkipTerminalState/HasMeasuredDuration: durations computed
  over measured rows only; skips leave the failure/success rate (decision
  2026-07-26 — a skip is not an install attempt); per-app skipped/unmeasured
  counts + totals exposed; slowest ranking gates on measured installs.
  Applied consistently: app metrics payload, apps dashboard (list/analytics/
  time series/trend/versions/device models), SLA weekly/snapshot/breach.
- StoreAppInstallSummaryAsync: reconcile extracted to internal static
  ReconcileAppInstallSummaryWithExisting (test seam). Fixes Q4 (terminal
  batch before started batch left duration permanently unset) and hardens
  terminal status against late/replayed InProgress batches.
- Web: measured-only hints on app detail cards, skip suffix in apps list,
  fleet-health "measured installs" caption.

Backend 3333/3333 (20 new tests), web tsc clean, vitest 627/627.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 16:24:45 +02:00
Oliver Kieselbach f311e8746b docs(trust): on-demand log collection in customer-facing claims + tracker dedup tests
The "Collect Logs" feature (b382e567) added a second opt-in path into hosted
diagnostics upload — the quick-config dialog on a session — and a new collection
trigger, neither of which the customer-facing pages described. The trust FAQ still
said hosted upload is enabled "in the settings UI", and the dialog itself named
hosted storage without stating that packages leave the tenant boundary, so the
"clearly marked disclosure" promise was not met on that path.

- CollectLogsButton: marked data-boundary disclosure in the quick-config dialog
  (leaves your Azure tenant, per-tenant isolation, blob-scoped write-only token,
  removal per Data Retention Days)
- privacy + about pages: both opt-in paths named; on-demand collection described
  (Admin/Operator of the own tenant, same content as the automatic package,
  delivered with the agent's next check-in, visible on the session timeline)
- docs repo counterpart in trust/security-faq.md (separate commit)

Distress-bell dedup: the TpmPssUnsupported one-shot bell had key-shape tests only.
New HardwareRejectionNotificationTrackerDedupTests pins the 409 dedup branch, the
fail-soft branch, the blank-input short-circuit, the disjoint hardware/tpmpss key
spaces, and the retention sweep — which also prunes tpmpss rows and thereby re-arms
that device's bell, now stated in the interface and implementation docs.

Backend tests 3313/3313, tsc clean, web vitest app/sessions 60/60.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:43:42 +02:00
Oliver Kieselbach 8a794a1b52 fix(rules): cross-tenant GA rule edits no longer misroute to home tenant
A Global Admin editing a gather/analyze rule while viewing a FOREIGN
tenant (override) got a green "saved" banner, but the write landed in
the GA's OWN home tenant: the JWT-scoped rules/{type}/{ruleId} routes
resolve the tenant from the caller's token, and no cross-tenant write
route existed (reads had /api/global/rules/*, writes did not). The
reload re-read the foreign tenant and showed the unchanged rule.

Fix: symmetric cross-tenant WRITE routes mirroring the global reads.

Backend:
- GlobalRulesFunction: PUT/DELETE global/rules/gather/{ruleId} and
  global/rules/analyze/{ruleId}; tenant from ?tenantId= (400 if
  missing), same 1 MB guard + ValidateScopeAndEmitMode as the JWT
  variants, same service calls (toggle-partial/built-in-state/full-edit
  semantics are tenant-parameterized and identical).
- Catalog: 4 entries GlobalAdminOnly + TenantScoping.QueryParam — a
  write tier, so the delegated-read rescue never fires; only a platform
  admin may mutate another tenant's rules. No global CREATE route:
  create stays JWT-only and the portal hides Create in override.

Web:
- api.rules.globalGatherRule/globalAnalyzeRule helpers; both rule pages
  route save/toggle/mark-failed/notify/delete through a
  *RuleWriteUrl(ruleId) switch on isGlobalOverride.
- Analyze template-enable (a create) is blocked in override with a
  clear message instead of silently writing to the wrong tenant.

Tests: CrossTenantAccessTests guards the 4 routes are GA-only +
QueryParam and that a GA reaches the named target tenant. Backend
3295/3295, web vitest 627/627, tsc clean.

Note: the old bug planted orphan copies of foreign rules into the GA's
home tenant on each attempted cross-tenant edit; those are not
auto-cleaned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 23:21:37 +02:00
Oliver Kieselbach 6c53dc33e7 feat(distress): tenant-visible TpmPssUnsupported devices + one-shot bell
TpmPssUnsupported distress reports (TPM cannot RSA-PSS-sign, Schannel drops
the client cert on Win11 25H2+, agent can never authenticate) were operator-
only, yet the remediation is tenant-side: TPM firmware update or device
replacement.

- New GET audit/tpm-pss-unsupported (MemberRead), aggregated per serial,
  mirroring audit/device-not-registered
- Bell notification "tpm_pss_unsupported" (Admin audience) once per
  (tenant, serial); dedup via the existing rejection tracker table with a
  disjoint "tpmpss|{serial}" row-key space; no webhook by design
- Web: TpmIncompatibleDevicesInsights panel in the Hardware Whitelist
  section; renders only when a device is affected (no permanent card)

No agent change, no ConfigVersion bump. Backend tests 3287/3287, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:47:06 +02:00