Commit Graph

1036 Commits

Author SHA1 Message Date
Oliver Kieselbach a0ad769231 RealmJoin packages count as app installs next to Intune apps
App install rows now carry their install channel. The ingest folds the RealmJoin
watcher's package started/completed events into AppInstallSummaries rows (channel
"realmjoin", keyed by scope + package id; duration = completion minus the observed
start). IME rows keep their key and have no channel column, so they read as before.

Every per-app aggregate groups on (channel, name): apps list, app analytics and
sessions (new ?source=, default ime), metrics/app, SLA top failing apps and the
version-regression radar (tracker key, episode key, bell link, ops event). The app
install SLA rate includes RealmJoin packages. The ESP-blocking stamp stays IME-only.
RealmJoin packages do not feed the software inventory or CVE correlation.

Web: a RealmJoin pill and an All / Intune / RealmJoin filter on Software > Installs,
the pill on app detail, Fleet Health and SLA, source-aware app links and PDF meta
line, and a hint in the empty Inventory tab. The pill moves out of InstallProgress
into a shared component. MCP: get_app_install_metrics names the source field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 10:19:18 +02:00
Oliver Kieselbach 5dae21968b Client-certificate chain building never downloads an issuer
The platform forwards whatever certificate the TLS client presented, so an
unauthenticated caller can hand in a leaf signed by a CA it made up. The
chain engine then fetched the leaf's AIA caIssuers URL before rejecting it:
an attacker-directed GET from the backend, and a worker blocked for the
10 s retrieval timeout. Every legitimate issuer is embedded and Intune
certificates carry no AIA, so downloads are now disabled and the unused
retrieval timeout is gone.

The regression test signs a leaf with a CA that is neither embedded nor
sent and asserts that the listener behind its AIA URL sees no connection;
a self-signed leaf never reaches that path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 09:06:09 +02:00
Oliver Kieselbach e816c92b88 Tenant configuration has one conditional write path; the full-model PUT is gone
A tenant configuration row is never written blind any more (D-290). Three writers saved
from the 5-minute instance cache and four wrote a fresh read without an ETag: auto-re-enable
could lift a suspension a Global Admin had just renewed, the PUT could write the cached
Disabled=false over the offboarding tombstone during the drain barrier, and a concurrent
first login could overwrite the write-once OnboardedBy.

- TenantConfigurationService.CreateOrUpdateAsync next to UpdateAsync (shared core): fresh
  read with ETag, tombstone refused, mutation decides, conditional replace, conditional
  insert for a missing row, re-read and re-decide on a lost race.
- SaveConfigurationAsync and IConfigRepository.SaveTenantConfigurationAsync deleted; new
  TryCreateTenantConfigurationAsync. The default row of GetConfigurationAsync is a
  conditional insert.
- Converted: first-login seed (writes only while the stored row has no domain; only the
  winning login fires the signup side effects), auto-re-enable (decided on the fresh row),
  auth client-id tracking, app-homing flip, conferred-Pro grace stamp, offboarding tombstone.
- PUT/POST config/{tenantId} removed (function, catalog entries, response DTO). The Global
  Admin tenant editor sends only its changed fields to the field PATCH; the Collect Logs
  quick-config PATCHes its two diagnostics fields with ?intent=collect-logs, which keeps
  the CollectLogsQuickConfigEnabled ops event. Shared diff helper lib/tenantConfigSave.ts.
- Tests: create-or-update and default-row races, concurrent first login, renewed
  suspension, tombstone paths; repository and service doubles in ConfigRepoMockExtensions.
  Regenerated shared manifest and wire types (web, MCP).

Deploy order: backend, then web right after (the old web's editor save and quick-config
use the removed PUT).

Bumps internal (D-290, plan, docs).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 20:11:56 +02:00
Oliver Kieselbach a16a70fba5 Self-service trial stays once per tenant across offboarding; plan writers decide on the fresh row
Offboarding deletes the tenant configuration, and the returning tenant's first login recreated
it with TrialConsumed=false, so the one-time 30-day Pro trial could be chained by offboarding
and re-onboarding.

- The offboard click reads the configuration fail-loud and ORs TrialConsumed into the
  OffboardingByTenantPointer (sticky across rounds, same ETag CAS as OffboardCount).
- The first-login write (HandleNewTenantDomainAsync, the one write that sets OnboardedBy)
  carries it onto the new row; an unreadable pointer means no write, retried next login.
  Re-onboarding itself is unchanged, auto-approve included.
- TenantConfigurationService.UpdateAsync: fresh projected row with ETag, offboarding
  tombstone refused (403 TenantSuspended), mutation may decline, conditional replace with
  the ordinary pre-write backup, re-read and re-decide on a lost race (409 after 3).
  SetTenantPlanTier and StartTrial use it, so a warm instance's cached row is never saved
  (it could lift the tombstone during the drain barrier) and two concurrent trial starts
  cannot both pass.
- Tests: TenantConfigUpdateTests (service + both endpoints), pointer stickiness and the
  offboard endpoint in TenantOffboardFunctionTests, first-login carry-over in
  AuthFunctionSideEffectTests; shared EndpointHarness.

Bumps internal (D-288, D-289, plan, docs).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 19:08:34 +02:00
Oliver Kieselbach c59e65ee0a fix(backend): webhook SSRF gate moves onto the connection
SsrfGuard.ValidateDestinationAsync resolved the webhook host and checked
it, then HttpClient resolved it again for the POST, so a DNS-rebinding
host could pass the check and be connected to an internal address.

The webhook client's SocketsHttpHandler now connects through
SsrfGuard.ConnectToPublicAddress: it resolves once per connection,
refuses the connection if any address is blocked, enforces HTTPS and
connects the socket only to the checked addresses; TLS still runs against
the request host. The pre-flight check is removed, the registration moves
to AddWebhookNotificationHttpClient so a test can prove the wiring, the
notification retry policy no longer retries a refused destination, and
the test endpoint reports the refusal with the same message as before.

IsBlockedAddress follows the same model as the MCP server: all IANA
special-purpose space, IPv6 only 2000::/3, zone ids and IPv4-mapped
addresses refused (IPNetwork.Contains judges a mapped address by its
IPv4 form, so those are refused first).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 12:36:06 +02:00
Oliver Kieselbach 889b843d6d feat(agent): Delivery Optimization geo endpoint as last-resort geolocation provider
When ipinfo.io (plus retry) and ifconfig.co both fail, the agent now asks
geo.prod.do.dsp.mp.microsoft.com/geo. It returns only CountryCode and
ExternalIpAddress, but it belongs to the DO/Windows Update endpoint set Autopilot
needs anyway, so it stays reachable where public geo services are blocked.
The session then gets a country-only device_location (source "do-geo", no
timezone, so timezone auto-set stays pending) instead of none.

- GeoLocationService: the three providers share one TryProvider skeleton;
  response parsers are internal and unit-tested; GeoLocationResult.Describe()
  skips missing parts ("DE" instead of ", , DE") in the event message and log.
- Failure trace carries doGeoError + lastResortProvider.
- Backend: geographic location keys skip empty region/city, so country-only
  sessions group as "DE" rather than ", DE"; drilldown still round-trips.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:08:56 +02:00
Oliver Kieselbach df640e3684 fix(backend): admin configuration is written only in its changed columns; PATCH global/config replaces the full-model PUT (D-285)
On 2026-09-25 a Global Settings save from a page loaded before an agent release wrote the whole configuration back and reverted the release's LatestAgentV2* hashes. After that, every 2.0.1465 agent reported IntegrityCheckFailed. The flaw was structural: every writer (portal PUT, plan tiers, three vulnerability-sync stamp paths) replaced the whole row from the copy it held, usually the 5-minute cache.

- IConfigRepository.UpdateAdminConfigurationAsync(mutate, updatedBy) is the one write path. It reads the row fresh with its ETag and applies the mutation to a copy materialized from that read. It computes the changed columns from the converter projections and writes only those onto the freshly read raw entity; a null value drops the column. The replace is conditional, and a conflict triggers a re-read and retry. A no-op writes nothing. Pipeline-owned and unknown columns survive. The full-model SaveAdminConfigurationAsync / SaveConfigurationAsync are removed, and the first row is created with AddEntity.
- PATCH global/config with { fields } (PatchAdminConfigurationRequest) replaces PUT/POST. Its field gate is derived from the persisted columns and rejects server stamps, LatestAgentV2* / LatestBootstrapV2ScriptVersion, PlanTierDefinitionsJson, the sync stamps and redacted placeholders. The route catalog lists PATCH.
- Plan tiers, TriggerMsrcSync, TriggerVulnerabilityDataSync, VulnerabilityDataSyncFunction and VulnerabilityCacheRefreshService.StampAsync each hand in a mutation.
- Tests: AdminConfigurationChangedFieldsTests (stale-page toggle keeps the release hashes, concurrent pipeline write re-read and kept, no-op, rejection, null drop, first row, gate); existing tests adapted; the request guard allows the admin field map.
- Regenerated shared manifest and wire types (web utils + MCP). The web still sends PUT until the web commit that follows the backend deploy.
- The internal pointer is bumped to D-285.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 13:51:53 +02:00
Oliver Kieselbach 2e736f40ad feat(backend,shared): Intune Enrollment Validation admits enrolled devices without pre-registration (D-282)
The certificate's Intune managedDevice id is looked up in the tenant's Intune. With the tenant
switch ValidateIntuneDeviceBinding on, that lookup is the last accepting link of the validator
chain: an enrolled device that is not being retired, wiped or deleted is admitted as
ValidatorType.IntuneEnrollment. A 404 for a certificate younger than 60 minutes is
NotFoundRecentEnrollment (503, never cached); a missing grant is 503 with 120 s.

For devices another validator admitted, the lookup only observes: in the background, never
admitting or rejecting, and only when the app token carries
DeviceManagementManagedDevices.Read.All, so a tenant without the add-on costs no Graph call.
Outcomes ride on the request row (CertDeviceBinding, role, serial match, owner and enrollment
type).

TenantConfiguration.HasAnyDeviceValidation() is the single gate precondition; the feature flags
expose it as deviceValidationEnabled (replaces validateAutopilotDevice).

The agent parses ValidatorType strictly: release the agent before this backend.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:57:26 +02:00
Oliver Kieselbach 1a96cd20da feat(web,backend): AI Integration settings page; one self-hosted client per tenant, raisable by a Global Admin
- Settings > Tenant > AI Integration (between SLA Targets and Bootstrap
  Sessions) explains the normal connection first - server URL for Claude,
  ChatGPT, VS Code and other hosted assistants, nothing to register - and
  shows the self-hosted client registration below it (Tenant Admin, platform
  switch on). The page is visible to admins, platform scopes and anyone with
  MCP access. The self-hosted example text is neutral.
- A tenant may hold one self-hosted client registration. The GA-only
  TenantConfiguration.McpClientRegistrationLimit (1-10) raises it on request:
  reverted for non-GA callers on PUT, refused on PATCH, edited in the Global
  Admin tenant dialog. MCP_SERVER_URL joins the portal URL registry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 15:00:15 +02:00
Oliver Kieselbach 93c6e1facb feat(mcp): tenant-bound registrations for self-hosted MCP clients (D-280)
A self-hosted AI client redirects to its own domain, which the vendor
redirect allowlist never admits, so it could not use the browser sign-in.
Tenant Admins can now register the client's exact OAuth callback in the
portal and configure the client with client id amc_<registrationId>.

- MCP OAuth proxy: an amc_ id is resolved through the backend; its
  registration is the redirect allowlist entry for that exact callback.
  Authorize and token run at the registering tenant's Entra authority, and
  the token endpoint discards a token of any other tenant (code and
  refresh). Lookups are cached for 60 s, a failed lookup fails closed. Every
  other client keeps the allowlist check before its resolution.
- Backend: table McpClientRegistrations, TenantAdminOrGA routes
  tenants/{tenantId}/mcp-client-registrations (list, create, delete; at
  most three, https or loopback http, exact URI, audited) and the anonymous,
  per-IP rate-limited lookup auth/mcp/client-registrations/{id} (under
  /api/auth for the client-certificate exclusion list).
- AdminConfiguration.McpClientRegistrationEnabled (default off) is the
  platform switch: off hides the portal section and makes every lookup 404.
  auth/me carries it for the sidebar.
- Portal: Settings > Tenant > Self-hosted AI clients (Tenant Admin / Global
  Admin), and the switch in Admin > Settings > Global.
- Tests: MCP tenant-client flow (authority, tid check, revocation, cache,
  allowlist), backend round trip, wire parity, validation, cap, switch,
  audit; nav visibility. Manifests and wire types regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:10:13 +02:00
Oliver Kieselbach c59b366560 feat(auth): client-app binding for delegated tokens - measured always, enforced behind a switch (D-279)
A user token obtained by an application other than the platform's own
registrations (for example a self-hosted AI client using on-behalf-of) was
accepted with the user's full role; only audience, issuer, signature and
lifetime were checked. Such tokens are now classified on every request.

- ClientAppBindingMiddleware (after authentication, before policy enforcement)
  records the request dimensions ClientAppBinding (registered | unregistered |
  unidentified) and ClientAppId, plus a throttled Warning.
- AdminConfiguration.EnforceClientAppBinding (default off; Admin > Settings >
  Global): when on, a foreign client is refused with 403 ClientAppNotRegistered
  unless the application is an enabled member of the caller's tenant (the
  app:<client-id> row a service principal uses). An admitted caller keeps their
  identity but gets the application caps: at most Viewer, no platform role,
  DelegatedReader on managed tenants, roleless tiers closed except auth/mcp.
- AdminIdentity.IsCapped (application or admitted foreign client) is what the
  global, delegated and member-role resolvers switch on; RequestContext carries it.
- Own clients = the audience trust set plus EntraId:TrustedClientAppIds, a
  client-only trust for the local development registration that never widens
  the accepted audiences.
- Tests: classification, unforgeable cap marker, middleware in both modes, caps
  through the real policy middleware, pipeline order; manifests and wire types
  regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:28:21 +02:00
Oliver Kieselbach efcb4f9ca1 fix(offboarding): farewell feedback reaches the backend again; farewell mail leaves an ops event; suspended page instead of alert
The offboard endpoint tombstones the tenant (Disabled=true, "Offboarding in
progress") before it answers 202, so the departing admin submits the farewell
feedback from an already-suspended tenant. Since the suspension gate moved onto
the shared authorization path (43ddc601, 2026-08-29) every feedback POST was
403 TenantSuspended — invisible, because nobody clicked and the two stored
feedbacks predate the gate (D-270).

- Endpoint catalog: `AllowedDuringOffboarding` on exactly
  POST tenants/{tenantId}/offboard/feedback; the middleware ignores the
  offboarding tombstone for that route only. An operator suspension still
  denies; policy tier, cross-tenant check and the function's active-marker
  check are untouched. Middleware tests for all four cases plus a catalog
  ratchet pinning the flag to the one route.
- Farewell mail: IOffboardFarewellEmailSender.SendAsync returns whether the
  provider accepted; the offboarding worker records FarewellEmailSent /
  FarewellEmailSkipped / FarewellEmailFailed like the welcome mail — the
  send's own success log is Information and never reaches Application
  Insights. Missing Email:ApiKey logs Warning. Handler tests per outcome;
  OpsAlertRules catalog and the generated manifests (Web + MCP vocabulary)
  carry the three types.
- Web: auth/me 403 TenantSuspended no longer shows alert() and forces a
  logout. AuthContext keeps the user signed in with claims-only info and
  ProtectedRoute renders a suspended page: for the offboarding tombstone
  "Your organization is being offboarded" with the farewell feedback form
  (a reload during the 6-minute preparation window keeps the form), for any
  other suspension the reason text plus contact links; both with sign-out.
  The feedback widget moved from OffboardingSection into
  components/OffboardingFeedbackForm.tsx.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 21:18:44 +02:00
Oliver Kieselbach ef280b7bb5 fix(backend): managed tenants see operator-provisioned access as "Platform support"
GET delegations/managers no longer exposes the name of an operator-created
Tenant Group to the customer; both operator shapes (groups and direct grants)
carry the neutral label. Assignees, reader count and source stay visible.
Web badge reads "Provisioned by operators". D-267.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:52:00 +02:00
Oliver Kieselbach c156350183 fix(backend): excessive-events sweep warns and blocks on recounted rows
The stored EventCount doubles on replayed upload batches and was only
corrected at terminal transitions, so the sweep judged running sessions on
an inflated number. It now recounts a candidate before it records the ops
event or blocks the device, skips candidates it cannot verify, and limits
the recount to candidates with a pending action. The warn text no longer
claims an agent loop bug.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:45:11 +02:00
Oliver Kieselbach 5163d7d6a0 refactor(backend): read the request query through req.Query only
73 sites in 68 files built the query collection with
HttpUtility.ParseQueryString(req.Url.Query), 15 files used req.Query and five mixed
both. HttpRequestData.Query is that same call, cached per request, and the ASP.NET
Core request type does not override it, so the change is behaviour-neutral.

- QueryParamGuardTests: ParseQueryString in the Functions project is limited to a
  named baseline of three sites that parse something other than the request query.
- A fact pins req.Query against HttpUtility.ParseQueryString (repeated keys, encoded
  values, caching) so a worker update that changes the implementation fails a test.
- Test mocks that set Url answer Query through CallBase; a loose mock returns null.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:28:11 +02:00
Oliver Kieselbach 9fd343d328 fix(backend): typed bodies and error envelope for backup, session-restore and delegation wrappers
Six functions wrote their responses through a private wrapper taking the body as
object, which no typed-response guard could see. Their error bodies carried the code
in the error slot, no correlation id, and RestoreRow returned the raw exception text
on a 500.

- Backup job status, row restore and backup trigger answer through ResponseHelper:
  errors are the envelope, the 500 is sanitized, the 202 body is BackupTriggerResponse.
- Same for the session-deletion maintenance trigger, RestoreSession's three error
  sites and the delegation acknowledgements (MessageResponse).
- Backup codes are constants (Constants.BackupErrorCodes) and part of apiErrorCodes;
  RunAlreadyActive and EnqueueFailed join ApiErrorCodes.
- TypedResponseGuardTests gains guard F: no HttpResponseData-returning method with an
  object parameter. BackupWireParityTests pins that the move from the backup
  serializer options to the worker pipeline leaves the wire unchanged.
- Web reads BackupTriggerResponse from the generated wire types.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:19:38 +02:00
Oliver Kieselbach 12af1d6c76 fix(backend): IME archiver accepts an existing archive entry before walking the hosts
A version whose installer was already in the archive could only turn
Archived through the 409 on the write-once upload, which needs a
distribution host that still serves the build. For a version no host
serves any more the row stayed Failed:VersionMismatch next to its own
blob and re-walked every host once a day.

The archiver now checks the archive first: an entry whose ProductVersion
is the observed version ends the job as Archived with the hash and size
of the archived bytes and no download; a missing provenance sidecar is
written without a source URL. An entry holding another build, or an
unreadable one, is not accepted and the walk runs as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 20:58:49 +02:00
Oliver Kieselbach 058da1627f fix(sla): alert and dashboard judge one rolling 30-day window; an empty period is no data; a lasting breach repeats only on new enrollments
The SLA breach alert judged the calendar month while the dashboard headline
showed the current ISO week, so an alert could report 78 % while the page it
links to showed 0 everywhere - and an empty week rendered as 0.0 % "Breached".

- SlaEvaluationWindow is the single definition of the SLA populations:
  success rate and P95 duration on terminal sessions of the last 30 days
  (rolling), app installs on the install attempts of the current ISO week.
  The breach evaluator and SlaMetricsService both read it, including the
  one-decimal rounding, so the value shown is the value judged.
- SlaMetricsResponse carries EvaluationPeriod and EvaluationWindowDays;
  SlaSnapshot carries Period and HasData. A period without a finished
  enrollment meets every target flag and is marked HasData = false.
- The dashboard reads from whichever starts earlier, the selected months or
  the evaluation window; trend and violators stay on the selected months.
- A lasting breach is notified again only after the cooldown AND once
  something finished since the last notification. A new breach episode after
  a resolve still notifies. Resolve notifications are unchanged.
- Alert period fact and telemetry tag follow the window constant.
- SlaMetricsService takes a nowProvider; the week-boundary workaround in its
  tests is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 15:08:16 +02:00
Oliver Kieselbach c701d0d694 Merge branch 'main' of https://github.com/okieselbach/AutopilotMonitor 2026-09-19 14:40:22 +02:00
Oliver Kieselbach d4a3edf849 fix(offboarding): a completed run removes the tenant's superseded dead letters (D-256)
A retry after a poisoned offboarding is a new envelope with a new history
row, and nothing ever deleted from a poison queue. The dead letter of the
failed run therefore stayed until its 7-day TTL and kept the poison backlog
alert firing daily for a tenant that was already gone.

TenantOffboardingHandler.HandleAsync now reports a TenantOffboardingOutcome.
On Completed the worker receives tenant-offboarding-poison in batches and
deletes the envelopes of that tenant; other tenants' and malformed messages
reappear untouched. The sweep never throws: a failure there would keep the
completed envelope on the main queue and end with a Completed run in poison.
A re-pickup of a completed envelope sweeps again.

Other poison queues stay terminal on purpose; their failures have no later
success that supersedes the dead letter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 14:09:28 +02:00
Oliver Kieselbach e164eac1eb feat(backend,web): manual maintenance trigger answers 202, the run is single-flight (D-255)
The trigger ran the whole maintenance inside the HTTP request (about three
minutes against a 230 s gateway limit): the portal reported a timeout while the
run went on, a second click started a second run, and nothing serialized those
runs against each other or against the 2h timer.

Backend
- MaintenanceRunGate: the timer and the manual run enter through one blob
  lease. A held lease writes MaintenanceSkippedLocked and runs nothing;
  MaintenanceStarted is written after the lease is held, the lease is released
  last.
- POST maintenance/trigger validates the date, probes the lease (409 while a
  run is active), enqueues on maintenance-trigger (fail-hard) and answers 202
  with the typed TriggerMaintenanceResponse. MaintenanceTriggerQueueWorker
  (self-polling) runs it; a failing or skipped run is not retried.
- The run report travels in the details of MaintenanceCompleted.
- BlobLeaseLockStore is the shared base of the lease lock stores;
  SessionDeletionMaintenanceLockStore derives from it, behaviour unchanged.
- The probe lets a failed lease release surface as 5xx: a probe lease still
  held at worker pickup would turn the queued run into a silent skip.

Web
- useOpsRunStatus: run status from ops events (latest Started newer than the
  latest Completed/Failed = active), used by the session-cleanup banner
  (rendering unchanged) and the maintenance trigger section.
- Trigger section: queued / already-active messages, status line, polls every
  15 s while a run is active, button disabled meanwhile. The 230 s fetch
  timeout is gone.

Shared manifest, generated wire types and the MCP ops-event vocabulary
regenerated (two new ops event types, reshaped response).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 13:50:06 +02:00
Claude c81591b498 feat(offboarding): operator retry of a Failed tenant offboarding from Tenant Management
A Failed offboarding was a dead end: the worker returns early on a Failed
history, the offboard re-click refuses to re-enqueue on a Failed marker, and
the marker cleanup only warns. Recovery meant deleting the marker row in Table
Storage by hand, while the tenant sat in the list as a tombstone counted on the
Waitlist (its whitelist row is wiped early in the cascade).

- GET global/tenants/{tenantId}/offboarding returns the offboarding record
  (marker status, failed phase, attempts, history details) as the typed
  TenantOffboardingStatusResponse; GA + Reader, never delegated.
- POST global/tenants/{tenantId}/offboarding/retry re-drives a Failed run: the
  History row keeps its RowKey and DrainCompletedAt (the worker's resume anchor)
  and counts the retry, marker and history go back to Initiated, the ordinary
  resume path re-affirms the Disabled-gate, waits out the remaining drain
  barrier and enqueues the envelope. Completed / in-flight answer 409, no
  marker 404, a missing history row 500 before any write. Global audit row
  RETRY/TenantOffboarding. Nothing retries on its own.
- Tenant editor: an "Offboarding" card replaces the Offboard action while the
  cascade owns the row (status, started by, attempts, failed phase and error);
  Failed offers "Retry offboarding…" behind the type-to-confirm dialog in its
  retry variant.
- Tenant list: tombstone rows carry an Offboarding badge (own Status facet)
  instead of Suspended, and no longer count as Waitlist.
- Tests: retry decision (reset order, drain anchor, conflicts, not found,
  enqueue failure, remaining-barrier delay), status projection, wire parity
  facts, filter facet. Shared manifest and generated wire types regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPG5WCmSwfNAv4xkK62n5r
2026-09-18 10:30:06 +00:00
Oliver Kieselbach ffb133f0b9 fix(backend): platform counters no longer freeze under retention (D-254)
The landing-page counters stood still since D-198: the two-hourly recompute
was their only writer and merged Max(live figure, stored figure), while
retention prunes the live figure below the stored one - so new enrollments
never showed up again.

- Hot path increments the per-tenant counters only (CAS): enrollments at the
  fresh registration, successes at the one status seam every Succeeded writer
  passes (transitioned && Succeeded), events per ingest batch. No batch
  touches the global row.
- The maintenance run floors the tenant counters at the live figures (one
  multi-field write per tenant), sums the tenant rows and adds the growth
  since the previous run to the platform row. The previous sums live in a
  baseline row written in one transaction with the platform row. A total is
  never derived from an absolute figure again: a shrinking source (retention,
  offboarding) costs the growth of one interval instead of freezing the
  counter. Without a baseline the sum is a lower bound only.
- TotalTenants rolls up the tenant rows with enrollments, UniqueDeviceModels
  a persisted set of seen models. TotalUsers stays a high-water-mark (a
  persisted user set would outlive the user-activity retention).
- The platform row is merged with its read ETag and never carries
  IssuesDetected: the former full-row upsert undid every increment that
  landed during the run.
- Reads that feed the baseline are not fail-soft, and a floor that could not
  be written skips the rollup of that run - a swallowed zero would be added
  as growth a second time.

Shared manifest and generated wire types: doc comment of PlatformStats only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:48:49 +02:00
Oliver Kieselbach b3fe321f0d fix(offboarding): owned-group wipe no longer poisons every tenant offboarding
The wipe of the tenant's own self-service Tenant Group handed the derived
group id ("msp-{tenantId}") to the tenant-GUID entry points of SafeWipeService.
Their GUID guard threw on every attempt, so each offboarding since the
self-service delegation change ran into max_dequeue and ended Failed after the
tenant tables were already wiped.

- SafeWipeService: WipeByOwnedGroupPartitionAsync / WipeByOwnedGroupRowKeyAsync
  take the tenant GUID and derive the group id themselves; matching filters in
  OffboardingFilters.
- The public wipe entry points are no longer virtual: they validate and hand
  over to protected virtual cores. Test fakes override the cores only, so the
  guard runs under every fake. With the old handler code 16 handler tests fail
  on the guard, which is how this shipped unnoticed.
- Tests: handler regression on the owned-group anchors, owned-group variants
  incl. verify-abort on a foreign group, filter shapes and guards.

Deploy and recovery of the stuck offboarding: B-5g6.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 09:41:21 +02:00
Oliver Kieselbach 7bbc2cbddf feat(backend): the tenant row records the accepted DPA version — HandleNewTenantDomainAsync writes Constants.CurrentDpaVersion (2026-09-1.7) into TenantConfiguration.DpaVersion in the same first write as OnboardedBy, once, and later logins never touch it; the field is system-owned (patch deny-list, PUT preserve, revert-protected), projected by config/all, the MCP tenant list and the GA tenant report; no backfill (D-252)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 19:30:16 +02:00
Oliver Kieselbach 71282f3556 feat(backend,web,mcp): the MCP quota follows the delegating tenant — every request is checked and counted on the caller's home tenant, a managed tenant is never charged or blocked by its manager's reads, and every delegation slot bought beyond the edition's included ones grows both the account and the organization windows by operator-editable per-slot values (D-250)
Backend/Shared:
- McpQuotaService: ResolveHomeAsync reads definitions, entitlements, plan name and purchased slots once per check; Grow adds slots × per-slot value to every window (a lifted window stays unlimited); the decision and McpPlanLimits carry the breakdown; CheckManyAsync, the charge scope and TargetTenantId are gone
- McpQuotaEnforcementMiddleware: one home charge for every caller; IsExempt = Global Admin only (counted, never blocked; the Global Reader stays quota'd); two 429 texts (organization / user)
- TenantEntitlementService.GetPurchasedDelegatedSlots = max(0, effective slot limit − catalog count), 0 without the delegation right; FeatureEntitlementCatalog gains McpSlot*RequestLimit (Pro 300/6000 per account, 900/18000 per organization = 30 % of the Pro windows; Community and conferred Pro 0)
- PlanTierDefinition gains four nullable slot fields; GET/PUT global/config/plan-tiers return the catalog (UsagePlanCatalogDefaults per edition) so a blank field is explained
- McpUsageQuotaNode / McpOrganizationQuotaNode carry purchasedDelegatedSlots and the slot values (absent without purchased slots)
- Removed: the Tenant Group flag ChargeHomeTenantQuota with its scope plumbing (HomeChargedTenantIds, IsDelegatedAggregate, CatalogDecisionResult), Narrow/quotaExcludedTenants on the session envelopes and the X-MCP-Quota-Excluded-Tenants header, targetTenantId on the 429 body, the foreign-home attribution on McpTenantUsage (HomeTenantId, delegated/homeTenantId on the organization report) and the managed tenant's budget on the manager's managed-tenant list; PATCH global/tenant-groups/{id} is a rename

Web: SectionUsagePlans with four slot inputs, catalog placeholders and a built-in-defaults line; SectionMcpUsage renders "base + slots × per-slot" on both cards and drops the delegated badge; SectionTenantGroups without the charge toggle; SectionDelegatedAccess, the accept page and the privacy page state the new model (privacy last updated 2026-09-11).
MCP: get_fleet_overview no longer merges an exclusion list, the 429 renderer names only the caller's organization, the delegated-scope instruction states the home-charge model; wire types regenerated.
Tests: quota service slot-growth suite, entitlement purchased-slot matrix, conferred-Pro self-service writes (403/409), parity facts for the new and the removed fields; the radar analytics fixture is dated off the real clock (it had aged out of the 30-day window).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 16:46:27 +02:00
Oliver Kieselbach 7080dcc45a fix(backend): rule catalogs — a GitHub reseed newer than the deployed build closes the embedded self-seed (RuleCatalogStamp), the IME self-seed sunsets retired patterns, and IME pattern health reads the global partition instead of the embedded catalog (D-249)
The self-seed of every fresh backend instance treated the embedded catalog as authoritative and wrote it over a newer GitHub reseed: on 2026-09-11 it re-inserted PS-SCRIPT-EXITCODE three hours after the reseed had deleted it (D-248), so agents ran 83 patterns. Every explicit reseed now stamps its kind in AdminConfiguration (PK RuleCatalog); the three self-seeds do nothing while a github stamp is newer than BackendBuildInfo.BuildUtc, and an empty table is still seeded from the binary. The IME self-seed deletes built-in rows the embedded catalog no longer ships.

ImePatternHealthService (health matrix and the ime_pattern_hits ingest filter) takes the pattern list from the seeded global partition via ImeLogPatternService.GetGlobalCatalogAsync, never from BuiltInImeLogPatterns, so the health page and the drift statistics follow a reseed without a deploy; the response carries catalog { source, stampedAt, patternCount } (manifests regenerated, web line, MCP description).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 12:09:18 +02:00
Oliver Kieselbach 197de1d718 fix(agent): the IME log tracker verifies and rewinds when a concurrent IME process overwrote bytes behind its bookmark; executor lines belong to the invocation at their file position (D-246)
IME's trace listener opens every log once per process with FileMode.Append and FileShare.ReadWrite
and writes at its own stream position afterwards. AgentExecutor.exe (two processes per user-context
script, one per detection script) has such a listener on AgentExecutor.log and on
IntuneManagementExtension.log, so a process that resumes logging after another one appended
overwrites those bytes: the final file holds the last writer, a byte-position tailer has read the
first version. Session 46749560 lost 6 of 16 platform-script results and 4 of 16 executor end
blocks with every tracker health counter at zero — results fell back to the exit code (one Success
where IME said Failed), one finished script stayed Incomplete. The agentexecutor_fallback results
in the fleet are this race, not late IME logging.

The tracker now keeps a fingerprint ledger of every entry it processed from the two multi-writer
files (FNV-1a 64 over the decoded bytes, one CmTraceEntryAssembler shared by the read loop and the
verification so entry boundaries are identical by construction), verifies it on every script end
signal, on a byte-budgeted cadence while an invocation is in flight (256 KB/s, at least 1 s
apart), in a 4 KB guard zone on every growth pass and on a fragment at the bookmark, and rewinds
to the first changed entry; entries whose (offset, hash) still read unchanged are skipped,
everything else is processed as backlog (never fresh, never a calibration anchor). The
policyId-less executor lines are owned by the nearest preceding invocation marker in their file
(PS-AGENT-ARG opens, PS-AGENT-SCRIPT-START upgrades to the platform script, PS-AGENT-INVOCATION and
PS-AGENT-COMPLETED close) instead of a last-started pointer, so an end block a rewind uncovers
reaches its own script. Restart seeds the ledger from the 64 KB behind the persisted bookmark;
a rollover clears it. Four restart-safe counters (ime_overwrite_rewinds,
ime_overwrite_bytes_reprocessed, ime_verify_passes, ime_verified_bytes) ride agent_metrics_snapshot
and ime_pattern_hits — recovered data, not skipped work, no new event type.

Tests: ImeLogTrackerOverwriteRewindTests (15) drive the positional-overwrite write pattern against
both files, including the two field scenarios with the shipped patterns; agent suite 2921 green,
backend known-actions list extended for the two new patterns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 23:40:20 +02:00
Oliver Kieselbach 6a85f45604 backend: agent request rows carry the admitting device validator and the proven tenant (D-244)
SecurityValidator stamps ValidatedTenantId once the certificate tenant binding
(or the bootstrap token) proves the tenant, and DeviceValidation once the
device-validation chain has run: the admitting ValidatorType name, None for a
tenant without a validator, Transient for the 503, Rejected for the 403 of
stage device. RequestTelemetryMiddleware resolves the row's TenantId in one
place (policy tenant, proven tenant, then the X-Tenant-Id header only on the
routes that validate it) and copies DeviceValidation as a dimension.

Register, config and upload-url rows carried no TenantId before, so
tenant-filtered KQL missed exactly the calls that run the validation, and the
validators' success line is Information-level and never reaches App Insights.
Proving which validator admitted a device is now one query.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:48:58 +02:00
Oliver Kieselbach 2b4dbe087a fix(web,backend): MCP Usage under a tenant override shows only that tenant (D-241 amended)
- GetMcpOrganizationUsageResponse.daily: charged requests per day inside the range from the same McpTenantUsage rows (BuildOrganizationDaily, MSP reads included); manifest + wire types regenerated (web + MCP copy); fold test
- Web: viewing another tenant no longer fetches the caller's own usage — no UPN, plan badge or own quota; the summary tiles and the daily chart come from the tenant's daily series. The own tenant stays the caller's usage page. Internal gitlink.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 03:38:58 +02:00
Oliver Kieselbach d2221eb0b9 feat(web,backend): MCP Usage page follows the global-admin tenant selector for its organization cards (D-241)
- Backend: GET global/metrics/mcp-usage/organization?tenantId= (GlobalReadOrAdmin, TenantScoping.None on purpose — the route enumerates accounts, so the delegated path stays out; 400 without a GUID, no aggregate); GetMcpOrganizationUsageResponse carries `quota` (tenant plan + daily/monthly windows summed from the same McpTenantUsage rows); McpQuotaService.ResolveTenantPlanAsync with public TenantPlanLimits; two folds tests
- Web: TenantScopeSelector on /settings/reporting/mcp-usage (override-only, hidden for a delegated scope), scopedApi.mcpOrganizationUsage, Organization-quota card from the organization response (falls back to the caller's own node only on the own tenant), latest-wins fetch guard, wording follows the viewpoint
- Manifest + wire types regenerated (web + MCP copy); internal + skills gitlinks

Deploy order: Backend before Web — under a tenant override the page calls the new route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 03:18:34 +02:00
Oliver Kieselbach baa51102fd feat(web,backend,mcp): app rows without a terminal state become Incomplete instead of running forever (D-237)
An app still installing when the session's observation ended has no terminal
event and never will. The session page ticked Date.now() - startedAt for it
("11h 39m" on a session that finished hours ago), and its AppInstallSummaries
row stayed InProgress forever - ranked behind failures in the app's session
list and counted in totalInstalls with no named bucket.

Web: applyObservationEnd marks such rows Incomplete once the session is
terminal (isTerminalStatus), bounded by session.lastEventAt; the panel shows a
slate Incomplete badge and the watched span as a lower bound, the "active"
pill excludes them, and the download panel carries the parallel flag. Covers
every historic session without a backfill.

Backend: CloseOpenAppInstallsForSessionAsync closes InProgress rows without a
CompletedAt as Incomplete - at the terminal seam, again on the ingest batch
that carries agent_shutting_down for a terminal session (rows opened after
the verdict), and from the 30-day sweep. The sticky-status reconcile keeps
Incomplete against a late progress-only batch and lets a real terminal win.
App metrics and app analytics disclose the bucket (incomplete,
totalIncomplete) outside the failure rate; manifest and wire types
regenerated.

MCP: get_session_summary coverage gains an apps block and a gaps line naming
the apps the agent's last app_tracking_summary still listed as installing or
downloading, terminal sessions only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 00:02:24 +02:00
Oliver Kieselbach 5577f5e7e5 feat(rules): rule submissions — retention (withdrawn 30d, declined 90d), contact email, pending-first tenant list, operator hard delete (D-236 amended)
Closed submissions leave through the daily maintenance sweep (RuleSubmissionRetention, one place for rule and test); pending/approved/published never. The submit dialog takes an optional contact email (a UPN is not always a mailbox), stored for the reviewer and never published. The tenant list shows pending rows by default with the rest behind Show all, and the contribute button shows for every writer, also before the first custom rule exists. DELETE global/rule-submissions/{id} (GlobalAdminOnly, MCP delete_rule_submission) removes test/demo rows for operator and tenant alike. Bumps internal and .claude/commands gitlinks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 23:27:25 +02:00
Oliver Kieselbach 61892592e4 feat(rules): community rule submissions — submit custom rules from the portal, review via GA page or MCP, publish by repo commit + reseed (D-236)
Tenant admins submit references to their own gather/analyze rules; the server freezes the rule, records pre-flight findings and fire stats, and stores it under a 12-character id (RuleSubmissions, SessionReports layout). Attribution is the submitter's frozen choice (anonymous by default) and becomes the published author. Review (approve with reserved id / decline with comment) through /admin/reports/rule-submissions or the MCP tools list_rule_submissions, get_rule_submission, review_rule_submission, reseed_rules_from_github; published is derived from the global catalog, never stored. Analyze rules now get the same server-side author stamp as gather rules; ANALYZE-ID-001 carries author Community. Privacy page names the credit; bumps internal and .claude/commands gitlinks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:53:32 +02:00
Oliver Kieselbach 8224c8f1a8 fix(rules,engine,agent): ESP-004 gates on the esp_apps_timeout verdict; not_exists absence gate; Shell-Core failure inherits settle detail (D-234, D-235)
ANALYZE-ESP-004 fired on every ESP terminal failure of a Continue-anyway
profile and rendered the configured FirstSync limit as elapsed time — the
mayHaveContinuedAnyway flag only says the profile has the button. The rule
now requires the agent's own verdict (app_install_failed with
failureType=esp_apps_timeout), names that app via a same-event filter, reads
failedSubcategory from esp_failure_settle_started and words the limit as a
limit. Verified with test_analyze_rule: cb7036a6 fires, 683f1eff (0x80070652
after 19 min) stays silent.

Rule engine: not_exists with a dataField on event_type/event_data
conditions matches when no event of the type carries a non-empty value —
including an absent type — instead of being dead behind the fieldValue null
short-circuit. Schema and MCP authoring guide say so.

Agent: a Shell-Core 62407 failure that lands inside the provisioning
tracker's 30-s settle window (a third of all ESP terminal failures) now
inherits the pending errorCode / failedSubcategory / category /
likelyCulpritApps, so enrollment_failed, Sessions.FailureReason and the
termination-time app classification see the HRESULT the registry had
already named. failureType stays the Shell-Core value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:20:24 +02:00
Oliver Kieselbach aa20d9102c fix(backend): show the freshly published agent version, not the previous one (D-230)
The What's new panel prints "Latest agent version" under the release
entries, and the session list draws its outdated badges from the same
endpoint — but the value was cached 12h in each Function instance and 1h
in the browser, so the panel announced the previous release for up to 13h
and two instances could disagree.

- LatestVersionsService: TTL 12h -> 5 min; keep the last successful result
  and serve it when the blob read fails, so the shorter TTL cannot blank
  the version line on a transient hiccup (failure marker 5 min -> 1 min).
- GetLatestVersions: Cache-Control max-age 3600 -> 60.
- No refresh callback from build-agent.yml: the cache is per instance, a
  warm-up request would leave every other instance stale.

The origin is a ~200-byte no-cache blob behind the download alias that
Front Door does not cache, so a warm instance now reads it at most 12x/h.

Backend deploy (workflow_dispatch) pending — B-uq7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:26:03 +02:00
Oliver Kieselbach 5fac3d6004 Agent Host changes for agents/whats-new-notification-settings (#153) 2026-09-08 20:51:20 +02:00
Oliver Kieselbach db9b1995bc refactor: move the error-code generator from scripts/ to tools/ (D-225)
scripts/ at the repo root is the customer surface: everything under
Bootstrap/ and CustomerSetup/ is published to the download container by
Publish-BootstrapScripts.ps1, Authenticode-signed and version-gated. The
error-code generator is a maintainer tool no workflow runs, so it diluted
that invariant. .github/scripts/ was not the alternative -- it holds only
helpers a workflow step invokes, and unlike guardrails-in-sync and
shared-manifests-in-sync the catalog has no CI drift gate (the generator
needs IME_ENUMS_JSON from operator tooling and live MS Learn fetches).

REPO_ROOT stays ../.. at the new depth, so only the documented path moved:
the generator header and README, the C# and TypeScript comments, the test
summary, and the description header of error-codes.json together with the
generator literal that emits it (the two must stay identical or the next
regeneration diffs). The web sync re-ran clean at 607 entries with no
change to utils/error-codes.json, which drops the header.
2026-09-08 20:35:51 +02:00
Oliver Kieselbach e33d85c01d Agent Host changes for agents/gather-rule-exitcode-info-update (#151) 2026-09-08 14:57:30 +02:00
Oliver Kieselbach 4e73ba8900 Agent Host changes for agents/improve-changelog-promotion-ui (#152) 2026-09-08 14:57:11 +02:00
Oliver Kieselbach 31535b07e0 web+backend: a warming MCP health card says only "Starting instance... (attempt n of 12)" and re-probes for ~60s before it warns (D-220)
The card composed its line from the backend's explanation ("did not answer within
3s - it scales to zero when idle ... ~20-30s"): probe mechanics nobody waiting on a
starting instance needs. It now writes its own one-liner and ignores check.message,
so the server's "warming" and a client-side probe timeout read identically and the
wording does not depend on a backend deploy. The backend message is shortened but
still carries the budget for direct API callers.

The re-poll budget of 8 x 5s (~40s) expired on a real cold start - Container Apps
activation has no upper bound we control - so it is now 12 x 5s (~60s), with a test
pinning that the stated seconds come from cadence x attempts.
2026-09-07 21:27:07 +02:00
Oliver Kieselbach 066b6e6f04 backend+web: the MCP health probe answers a cold container in 3s with status "warming" instead of holding the request for 30s, and the System Health card re-probes until it is up (D-220)
Measured over 14 days, GET health/mcp answered in 4..148ms when warm and timed out at 30s
in 50 of 83 calls — the median call took 27,972ms. A cold start is 20-30s of Container Apps
activation (~98% KEDA plus image mount, ~500ms application), so no UI wait covers it and the
generous timeout only bought a long wall in front of the same answer. The browser's own
budget is the same 30s, so the backend's carefully worded warning never reached the user at
all: they saw a raw DOMException.

Backend: the budget drops to 3s, which sits in the widest observed latency gap
(2347ms..3930ms) so jitter cannot flip the verdict. Our own cancellation now reports the new
status "warming" at Information level (with minReplicas=0 a cold probe is the expected path,
and logging it as a warning poisons the ops signal); HttpRequestException is split out of
that catch and stays a real warning, still without the host in the message. The warm-hit
threshold drops to 750ms — ~5x the warm maximum, deliberately generous because every
measurement comes from a near-idle single-user system and warm latency will rise under
concurrency — and moves into McpReachableMessage so a test can pin the boundary. The XML doc
now describes the probe that exists rather than the wake-signal design the measurements
disproved.

"warming" is a new status value rather than an added DTO boolean because the backend deploys
before the web: an unknown value degrades to a grey card with a clean banner, whereas a
boolean would leave old web showing yellow and "Warnings detected" on every cold start.
HealthCheck.Status now documents the five-value vocabulary, which rides into both generated
TypeScript mirrors as JSDoc.

Web: resolveMcpCardState owns display, banner rating and the poll decision as a pure
function (the suite runs on node, so testable logic cannot live in JSX). A warming server
re-probes every 5s for up to 8 attempts and stays out of the overall banner exactly as
"unknown" already does — scaling from zero is expected behaviour, not a degraded platform —
and only becomes a real warning once the re-polls are spent. The card keeps showing
"warming" while the next probe is in flight so it cannot flicker, carries the blue
in-progress look instead of yellow, and was hoisted out of the healthResult guard: a failing
detailed check would otherwise hide a card that is still polling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 18:25:56 +02:00
Oliver Kieselbach 7ef097529c backend: deleting an owned tenant group stamps the retention grace anchor on every member (conferred Pro ended without ProDowngradedUtc)
The portal hides Delete for owned (msp-{home}) groups, but DELETE global/tenant-groups/{groupId} accepts any id: a Global Admin deleting one ended Pro (MSP) for every member with no grace anchor, so a member that raised its retention under Pro was clamped to the Community cap at the next sweep. DeleteGroupCoreAsync now snapshots the owner and calls ProConferralService.RecordLossAsync(member, owner, "group-deleted") per member, owner excluded, independent of assignees. Two tests in RevokeEnforcementTests; internal docs list the fourth writer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 00:15:21 +02:00
Oliver Kieselbach e7df69efe4 backend+web: vulnerability summary carries a priority breakdown (act/attend/track); the exposure panel shows Act and Attend tiles and every severity or priority chip filters the top-CVE list (D-218)
Act equals the KEV set by construction (CvePriority: KEV => act), so the KEV tile becomes the Act tile and Attend joins it instead of a duplicate count. The breakdown is counted in the backend over every CVE in the window, like the severity breakdown, because the list is top-N. Both panel consumers read the generated CveExposureSummary type; the MCP tool description names the new block. Wire types regenerated for web and MCP.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:30:36 +02:00
Oliver Kieselbach 2c7efa2733 backend: key-safe X-MCP-Tool-Name prefix for the usage row key (CodeQL cs/log-forging #91)
The caller-supplied X-MCP-Tool-Name header went verbatim into the UserUsageLog
row key ({yyyyMMdd}_{tool}:{endpoint}) and from there into the CAS-conflict
warning of TableCasRetry. EndpointNormalizer.ToolNameKey now strips CR/LF and
every other control character or line separator, replaces the characters Table
Storage rejects in keys (/ \ # ?) and caps the length at 64; an empty result
skips the prefix. Real tool names pass through unchanged, so existing usage
rows keep their keys. A header with an illegal key character previously made
every usage increment fail with 400 (fail-soft, counter never advanced).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:19:56 +02:00
Oliver Kieselbach d5d1d7bfbd backend+web: one agent-config derivation (AgentConfigResolver) served on agent/config and GET config/{tenantId}/effective-agent-config; wire-type initializer defaults in the shared manifest; the Tenant Config Report reads both (D-216)
The report still carried two hand copies after D-215: a typed map of C#
initializer defaults for the "custom" highlight, and a TypeScript mirror of
GetAgentConfigFunction's resolution rules for the runtime column.

Backend: the derivation moves verbatim into Services/AgentConfigResolver
(Build = pure, ResolveAsync loads tenant + admin config and the two rule
catalogs). GetAgentConfigFunction keeps the kill verdict, the migrate logging
and the device fields on top; ParseAgentMajor, ResolveDiagnosticsUploadEnabled,
MergeDiagnosticsLogPaths and ResolveMigrateTarget move with it (call sites and
tests retargeted; the QueryParams ratchet baseline shrinks by the file). New
GlobalAdminOnly route config/{tenantId}/effective-agent-config returns the same
AgentConfigResponse without a device (optional ?agentVersion=, default the V2
line). AgentConfigResolverTests pin the rules that used to live in the web:
upload 10, Hosted destination without SAS, SAS regardless of mode, unrestricted
only with entitlement + gate + toggle, operator knobs from AdminConfiguration,
analyzer defaults, hash line per major, migrate override and rejection.

Manifest: SharedManifestParityTests emits "defaults" next to "fields" for
TenantConfiguration and AdminConfiguration and an "agentConfig" section with
the AgentConfigResponse / CollectorConfiguration / AnalyzerConfiguration class
defaults (fresh-instance values; nested objects null). TenantConfiguration.
NtpServer gets the initializer "time.windows.com" the table read already
applies, so the manifest default and a stored row agree.

Web: the catalog rows lose value() and default; the runtime column renders
the endpoint's response, the highlight compares against the manifest defaults,
informational rows (reasons, timestamps, identities, secrets) never read as
custom. Previously excluded fields are visible (configVersion, catalog counts,
sealing pattern ids, hash oracle, migrate target); the collector class
defaults sit in a collapsed "Collector Internals" section. Fail-soft: while the
backend is not deployed the runtime column shows a notice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:59:34 +02:00
Oliver Kieselbach 43f5304ba4 web+backend: dashboard search gets field qualifiers (model=, manufacturer=), AND-ed terms and exclusions, parsed identically on both sides; Fleet Health model rows link with that search (D-217)
The "Health by Device Model" link searched "{Manufacturer} {Model}" as one substring; the server's
free-text predicate tested it per field, so once the failed sessions were outside the loaded first
page the sweep returned nothing.

- lib/searchQueryTokens.ts: tokenizer shared by the timeline and the session search (terms, quotes,
  leading minus, key=value / key:value qualifier when the separator precedes any quote)
- app/dashboard/utils/sessionSearchQuery.ts: 15 qualifiers = exactly the fields both sides search,
  matcher over session fields + client-derived extra text, suggestion field lookup, link builder
- useDashboardFilters / SessionTable: filter and suggestions use the grammar; fuzzy only for a
  single free term; session_searched gains a `qualifiers` count
- Helpers/SessionSearchQuery.cs: the same grammar for q= on /api/search/sessions (+ global), parsed
  once per request; MatchesFreeText is a thin wrapper
- utils/session-search-syntax.cases.json: one case file run by vitest and by
  SessionFreeTextSearchTests (parity proof)
- FleetModelHealth/FleetSlowModel/FleetFailingModel: manufacturer + model as stored plus label,
  grouped on the pair; useFleetHealth uses the generated wire types; "Unknown" is not a link
- shared manifest + wire types regenerated for the three DTOs

Backend suite 6158/6159 (the one failure is SharedManifestParityTests against uncommitted WIP of a
parallel task), web vitest 1177/1177, build 0 warnings.
2026-09-06 21:54:10 +02:00
Oliver Kieselbach 864d1ed31e backend: MCP usage counters go through TableCasRetry with an 8-attempt jittered budget instead of three immediate retries (B-c92)
Parallel tool calls of one user hit the same (user, day, endpoint) row in the same instant; without backoff the losers stayed synchronised and 3 of 6 increments were lost. Both increments now use the one CAS helper (merge patch, AddEntity on 404, StorageCasConflict marker) with UsageCounterRetries = 8; the tenant counter's attribution columns travel in the merge patch. Tests pin the budget, the patch shape and the create path.
2026-09-06 19:04:58 +02:00
Oliver Kieselbach 66452621c6 exit-code class per app + reboot rule: IME-EXITCODE-CLASS, ANALYZE-APP-018, exitCode on every terminal app event (D-214)
- IME-EXITCODE-CLASS captures the return-code class of the installer exit code ("lpExitCode is defined as <Success|SoftReboot|HardReboot|Retry|Failed>", verified against the IME string literal) as exitCodeClass on AppPackageState; new action captureExitCodeClass, persisted across the agent restart a reboot causes, last value wins across a Retry loop. IME-EXITCODE-MAPPED-FAILED keeps the error path; both match the same line.
- Adapter: exitCodeClass whenever known, exitCode on every terminal state (Installed/Postponed/Skipped/Error) instead of Error only.
- ANALYZE-APP-018 (warning, apps): app_install_completed.exitCodeClass in SoftReboot,HardReboot; +25 HardReboot, +15 observed system_reboot_detected; never fails the session.
- Web: completed-app exit badge amber (failed stays red), "mapped as SoftReboot/HardReboot/Retry" on timeline and Install Progress.
- Tests: pack end to end (phase, current app, class lines, negatives, restart round trip), adapter payloads, linearity samples, backend rule engine + known actions.
- internal gitlink: plan, D-214, backlog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:08:49 +02:00
Oliver Kieselbach 0dcf0eb503 Observation coverage in get_session_summary, absence audit with coverage lint, diagnostics packaging counters (D-213)
MCP: coverage block (window, IME tracker, collectors, upload, diagnostics, gaps) built from the
health events the summary already fetches (session-coverage.ts, data.<key> slices); validate_rule
warns on a required not_exists of an IME-log-derived event without the ime_tracker_degraded
precondition; authoring guide 'absence'; instructions and prompts point at coverage.gaps.
Rules: guardrails.json imeLogDerivedEventTypes (schema + combine). Backend: catalog invariant test —
absence rules carry the coverage gate or a documented exception (ANALYZE-ID-004).
Agent: diagnostics_uploaded/_upload_failed carry packaging counters (truncated, includedFiles,
skippedFiles, skippedByReason, problemsByKind) — counts only, never paths.
Also: shared-manifests.json regenerated (was stale at HEAD; McpAccessPolicy doc string) + codegen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 17:35:52 +02:00