Commit Graph

2218 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 c421b81751 web: fix all 16 exhaustive-deps lint warnings, ratchet cap 115 -> 99
Wrap ad-hoc fetch closures in useCallback and complete effect deps
(SectionPlatformUsage, usage-metrics, diagnosis, TenantConfigContext,
SignalRContext, dashboard, fleet-health, useProgressSignalR). Convert
sla's initialLoad to a ref so the fresh-once flag can't retrigger the
fetch effect. Document the three intentional live-ref bumps in unmount
cleanups (cancellation counters) with justified disables.

Also fixes a real stale-closure bug: TenantConfigContext's save callback
serialized webhookNotifyOnHardwareRejection without depending on it, so
toggling only that setting saved the old value.

The 3 react-compiler preserve-manual-memoization infos remain (compiler
bailouts, not defects) - cap ratcheted to the exact current count of 99.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:10:04 +02:00
Oliver Kieselbach 7636a00e24 web: fix lint regression from Load-all — lift sweep state into useDashboardSessions
The Load-all button reconstructed 'sweep running' locally via a
set-state-in-effect on loadingMore, adding a 116th react-hooks warning
over the 115 cap. The hook already knows loadMore vs loadAll apart, so
expose loadingAll from useDashboardSessions and drop the local state and
effect entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 14:39:51 +02:00
Oliver Kieselbach 90294455e0 added "Load all" link 2026-08-06 14:26:11 +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 6bab2d3db9 updated command 2026-08-06 13:53:56 +02:00
Oliver Kieselbach 78b8dd0f1b tests: fix ContinuationToken key-rotation flake — scope the test key to the async context
The rotation test swapped the process-wide static test signing key
mid-test; xUnit runs test classes in parallel, so any pagination test
encoding/decoding during that window failed with bad_signature instead
of its expected reject reason (seen as order-dependent one-off failures
in CI). New AsyncLocal-scoped override confines the swap to the
rotation test's own execution context; the process-wide TestSetup
default is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:51:25 +02:00
Oliver Kieselbach 14acfd5c59 rules: renumber Secure Boot rule to ANALYZE-SEC-008 — SEC-002 is a retired ID
ANALYZE-SEC-002 was the sunset AutoLogon rule; BuiltInRulesTests pins
that the ID never returns (the CI failure on main). Reusing a retired
ID would also inherit any historical tenant state keyed to it. Rule
content is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:51:16 +02:00
Oliver Kieselbach 22d4c6c677 updated commands 2026-08-06 13:30:29 +02:00
Oliver Kieselbach 9fce499f4c rules: ANALYZE-SEC-002 — warn when Secure Boot is disabled
Fires on secureboot_status.uefiSecureBootEnabled=false; legacy BIOS
devices (registry key absent, agent sets note field) are skipped via
precondition. +20 confidence on physical devices. Validated with MCP
validate_rule (0 findings) and test_analyze_rule dry-runs: fired at 90
on a physical SecureBoot:False session, silent on a SecureBoot:True
session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:27:19 +02:00
Oliver Kieselbach 8c7a6410d2 web(mcp-usage): mobile-friendly layout for admin + reporting sections
Headers stack below sm, summary cards go 2-up on phones, top endpoints
show tool name with route as secondary line, and the top-users table is
replaced by stacked rows so requests/last-active are no longer cut off
on narrow screens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 12:01:10 +02:00
Oliver Kieselbach c158768343 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-05 22:55:50 +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 179a441e64 updated commands 2026-08-05 20:21:53 +02:00
Oliver Kieselbach acc95471a6 bootstrap(dev): Pester-testable structure + guard/relax test suite + CI
Refactor Install-AutopilotMonitor-dev.ps1 (v2.3-dev) into functions
(Get-CloudPcModel, Get-RealUserProfilePaths, Get-RelaxDecision,
Get-BootstrapDecision with reason codes) with an Invoke-Bootstrap main and
a dot-source entry guard, so the Pester suite can load the logic without
executing the bootstrap. Behaviour when run directly is unchanged.

Fixes a refactoring leak found by smoke-run: Write-Log used Write-Output,
which turned function return values into arrays and swallowed console
output; now Write-Host, guarded by a dedicated logging-purity test.

Add Install-AutopilotMonitor-dev.Tests.ps1 (25 tests: Cloud PC detection,
relax scenarios incl. the W365 field case, guards 1-5, profile collection
with WMI failure path) and .github/workflows/pester.yml running the suite
under Windows PowerShell 5.1 (IME runtime) plus an ASCII guard over all
bootstrap scripts. Tests pass 25/25 under PS 5.1 and pwsh 7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:17:10 +02:00
Oliver Kieselbach 29ced81d6e docs(okf): tool-telemetry — record the real SDK 1.30 rejection shape
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:15:09 +02:00
Oliver Kieselbach 4966d23a46 fix(mcp): rejection sniffer must match the isError-result shape, not a JSON-RPC error envelope
Second live miss, root cause found via end-to-end repro against the real SDK
stack: SDK 1.30 catches every McpError around the tool handler (Zod input
validation, unknown/disabled tool) and answers with a SUCCESSFUL JSON-RPC
response whose result is a CallToolResult { isError: true, text: "MCP error
<code>: ..." } — a JSON-RPC error envelope never exists for these. The sniffer
now recognizes both shapes and extracts the code from the text prefix; handler
soft errors (toolError) never carry that prefix, so they are not double-logged.
Repro verified: the sniffer logs tool_call_rejected against a live
express + StreamableHTTPServerTransport + zod-tool stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:14:21 +02:00
Oliver Kieselbach 59ba8301a8 fix(mcp): rejection sniffer must capture res.write chunks, not only res.end
Live verification caught the miss: tool_call and search_zero_hit lines arrived,
tool_call_rejected did not. SDK 1.30 bridges the web-standard Response through
Hono's getRequestListener, which pipes ReadableStream bodies as Uint8Array
chunks via res.write followed by a BARE res.end() — the end(chunk) inspection
never saw the envelope. The sniffer (now attachToolCallRejectionSniffer in
telemetry.ts, unit-testable) wraps both write and end, reassembles up to 10 KB
and decodes string/Uint8Array chunks; tests pin the streaming path, the direct
end(string) path, the over-cap silence and the disabled no-wrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:05:22 +02:00
Oliver Kieselbach b142fa0994 updated bootstrapper for W365 devices 2026-08-05 19:49:39 +02:00
Oliver Kieselbach afa8750cbb feat(mcp): tool-call quality telemetry — soft errors, result size, zero-hit searches, arg rejections
withToolTelemetry now receives the tool args and inspects the RESULT: handlers
never throw (toolError returns isError:true), so the previous isError flag was
effectively always false. Each MCP_TOOL_LOGGING line now carries soft-error
status, resultChars + overCap (vs. the anthropic/maxResultSizeChars hint),
a size-bounded args summary and the caller scope — the only telemetry that
covers backend-free tools (search_docs, validate_rule, get_resource).

New signals: search_zero_hit on the three search tools (unmet-demand signal)
and tool_call_rejected for JSON-RPC-level Zod/unknown-tool rejections, sniffed
at the /mcp route since the SDK answers those before any handler runs.

Bicep default toolLoggingEnabled=true (desired state; live flag via
az containerapp update). Docs: docs/mcp/tool-telemetry.md with KQL examples.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:16:35 +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 b4e409783e updated commands 2026-08-04 20:23:21 +02:00
Oliver Kieselbach eb94c612e7 fix(agent): gather phase/event triggers follow the engine-reduced timeline
phase_change/phase_exit/on_event gather triggers (and the phase-scope
gates) were driven by the pre-reduce SignalIngress.SignalPosted stream,
so any raw signal carrying an EspPhase payload fired them before the
decision engine had applied its gates. In session 32312a32 (RealmJoin
tenant) a Phase Start rule on FinalizingSetup fired at the raw ESP-exit
EspPhaseChanged(FinalizingSetup) signal, 7 minutes before the engine's
RealmJoin-gated phase_transition(FinalizingSetup) - and read
HKLM\SOFTWARE\RealmJoin\Custom\BIOS before the RealmJoin package that
creates it had run (exists=false).

GatherRuleExecutorHost now subscribes to the new TimelineEventStream:
EventTimelineEmitter publishes every emitted event's (eventType, phase)
post-reduce, so phase triggers fire exactly when the UI timeline shows
the phase, and on_event sees engine-emitted event types
(enrollment_complete, phase_transition, realmjoin_resolved, ...) that
never existed on the raw signal stream - restoring V1 semantics and the
documented "On Event with enrollment_complete" pattern. Raw-signal
consumers with OS-state semantics (DeviceInfoHost, UserEspKeepAwakeHost,
ProvisioningPackageHost) stay on the raw feed deliberately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2.0.1389
2026-08-04 14:44:35 +02:00
Oliver Kieselbach ed5fcf3dc3 chore(deps): bump transitive npm deps to resolve Dependabot alerts
- mcp: hono 4.12.31->4.13.0 (ReDoS in CORS middleware), fast-uri
  3.1.4->3.1.5 (host confusion), ip-address 10.1.0->10.4.0 (SSRF
  bypasses), postcss 8.5.21->8.5.25 (sourceMappingURL file read)
- web: postcss 8.5.22->8.5.25, brace-expansion (DoS, npm audit)

Lockfile-only changes within existing semver ranges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:57:04 +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>
v2.0.1388
2026-08-04 11:54:41 +02:00
dependabot[bot] efc96258d6 chore(deps-dev): bump the web group (#122)
Bumps the web group in /src/Web/autopilot-monitor-web with 2 updates: [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) and [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom).


Updates `@types/react` from 19.2.17 to 19.2.18
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@types/react-dom` from 19.2.3 to 19.2.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.2.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 11:19:47 +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 f3028041af feat(web): tenant list shows plan badge (Community/Pro) instead of Preview
The green "Preview" badge was the activation-gate whitelist marker from the
preview era. With public availability + auto-approve live it carried no
information. Rows now show the plan tier (legacy "enterprise" resolves to
Pro, everything else Community); the amber "Waitlist" badge still appears
for unapproved tenants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 15:50:56 +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 5edb64f3c9 fix(web): confirm-dialog errors surface in-dialog + dark-mode sheet gaps
The GA offboard and app-homing confirm dialogs reported failures via the
admin page-level error banner, which sits behind the tenant editor modal
(z-50) and the dialogs themselves (z-[60]) - the user saw nothing. Both
dialogs now take an error prop and render the failure inline; the section
routes homing/offboard failures into dedicated states cleared on open.

Dark mode: class-by-class audit of the offboard UI against the global
override sheet. The dialogs were already covered; the real gaps were
missing family members in globals.css - border-{red,amber,green,purple}-100,
border-{blue,sky,yellow,rose,emerald}-300, and text-red-600/700 (mapped to
red-400, mirroring the cyan/rose 600-to-400 convention).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:56:17 +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 1bfdb68ff6 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-02 18:02:08 +02:00
Oliver Kieselbach 5ee5114d5c updated modification datetimes page 2026-08-02 18:02:04 +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 2a5ad65dd7 fix(web): explicit JSX spaces where Turbopack eats them - "onlyto" class of typos
Turbopack drops the ambient space between an inline element and adjacent
prose in certain multi-line JSX text layouts (Babel/SWC preserve it), so
the live portal rendered "onlyto reach you", "IsEnabledflag",
"...ps1script", "5 minutesfor GitHub's", "Unavailableand the cascade".
User-reported on the Contact section; the other five sites were found by
scanning the compiled bundle for space-less text nodes after elements.

Fix: explicit {" "} at each affected boundary - it compiles to its own
text-node and cannot be trimmed. Verified in a fresh build: all six sites
now emit ..."})," ","to reach... Sites whose gap comes from ml-*/mx-*
margins or flex gap were left alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:31:04 +02:00
dependabot[bot] 960b3d8ed9 chore(deps): bump github/codeql-action from 4 to 4.37.3 (#119)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.37.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 16:52:33 +02:00
Oliver Kieselbach 86bb255b11 feat(web): GA offboard button in tenant management - with type-to-confirm and ban-semantics note
The admin tenant editor gains a danger zone that queues the existing
offboarding cascade (DELETE tenants/{id}/offboard, policy already admits
GA). Guarded by a type-OFFBOARD confirmation dialog so a stray click can
never trigger it, Global Admin included.

Both the suspension block and the danger zone now spell out the
semantics: suspension is the durable lock-out lever (blocks sign-in and
auto-activation, data stays); offboarding is NOT a ban - the cascade
deletes the config row including any suspension, so a completed offboard
lets the tenant re-onboard and auto-activate on the next sign-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 16:48:53 +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 427c923530 feat(web): mobile menu for landing navbar - sign in was unreachable on phones
Below md the landing navbar only showed the Get started CTA: Sign in was
hidden sm:block and the nav links (Product/Capabilities/Compare/Docs) had
no mobile counterpart at all. Returning users on a phone had no visible
way to log in.

- burger toggle (<md) opens a solid panel under the bar: nav links,
  GitHub, and a prominent Sign in button; closes on link click, backdrop
  tap, and Escape
- Get started stays always visible in the top bar; GitHub icon and Sign
  in text button move into the menu below md
- panel uses opaque --lp-surface: nested backdrop-blur inside the nav
  does not blur page content, a translucent panel let the hero bleed
  through
- 320px fit: px-4 below sm and the wordmark hides below 360px (mark only)

Verified via Playwright at 320/390/1280px: menu open/close paths, anchor
scroll to #features, desktop layout unchanged; tsc and ESLint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 13:12:34 +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 6e294c010e fix(web): validation-gate toggles persist - disable was local state only
Prod report 2026-08-01 (tenant b54dc1af): corporate-identifier validation
"came back on" after every disable. Audit log shows the truth: not a single
True->False entry ever - the disable NEVER reached the server. The validation
section has no save bar, and only the consent-driven ENABLE paths persisted;
the toggle setters (disable via confirm dialog, and enabling the second gate
while the first already carries the consent) wrote plain React state that
silently reverted on the next config load.

New TenantConfigContext.saveValidationGate routes those setter calls through
the shared config PUT with explicit override values; local gate state re-syncs
from the server response (or snaps back on failure), and the section save
indicator covers the write.

tsc clean, vitest 631/631, ESLint ratchet holds (115).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:32:51 +02:00
Oliver Kieselbach dc54c9555c fix(web): correct Entra roles for admin consent - Application Administrator is not sufficient
The validation info box and the detect-access error still claimed Application
Administrator could grant the consent. Per MS Learn (grant-admin-consent),
Application/Cloud Application Administrator are excluded for Microsoft Graph
app roles (application permissions) - our consent includes the app-only
DeviceManagementServiceConfig.Read.All. Correct roles: Global Administrator
or Privileged Role Administrator, matching the funnel banner and docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:21:28 +02:00
Oliver Kieselbach 784fd99240 fix(web): consent verify rides AAD propagation; funnel banner actions + dark mode
- Post-consent verification now POLLS the access-check (up to 5 attempts,
  15s apart) instead of a single shot: right after a successful admin consent
  the app-only token often mints with an EMPTY roles claim for 30-90s
  (observed live: tenant 5ca2b350, GrantedRoleCount=0 at T+43s), which the
  single-shot probe misreported as "permission not granted" while the homing
  flip and consent were actually fine. The access-check invalidates and
  re-mints fresh per call, so the poll converges as soon as Microsoft has
  propagated the role; the consent spinner stays up meanwhile. Final "absent"
  message now names propagation as the likely cause instead of sounding fatal.
- Funnel banner gains always-visible actions ("Grant consent for the new app"
  + "Detect existing access" on the autopilot gate, idempotent when already
  enabled): tenants whose validations are already ON had no consent button to
  click, and the per-toggle detect link under Corporate Identifier would
  enable that validation as a side effect.
- Dark mode: funnel/flipped/legacy banners and the admins-only notice were
  light-palette-only and unreadable on dark.

tsc clean, vitest 631/631, ESLint ratchet holds (115).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:15:50 +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 8d4f7ea0f9 fix(web): app-homing funnel banner - prominent card with permission hint + docs link
The plain info box was easy to overlook. Upgrade to a bordered gradient card
with icon and heading, state that admin consent needs a tenant-wide-consent
role (Global Administrator / Privileged Role Administrator), and link the
portal-setup docs article describing the required permissions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:17:54 +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