Commit Graph

2708 Commits

Author SHA1 Message Date
Oliver Kieselbach 54259f070f chore: bump internal gitlink (request-wire-contract GEBAUT, D-207, B-r95/B-cme) 2026-09-06 11:42:37 +02:00
Oliver Kieselbach 26cef3ab06 web: request bodies as jsonBody<T> against the generated wire types (D-207)
Every request body in app/, components/, contexts/, hooks/ and lib/ is encoded
as jsonBody<SomeRequest>(…) from lib/apiClient.ts; the apiClient ratchet gains
the `body[:=] JSON.stringify(` pattern (74 → 10 sites) and refuses a bare
jsonBody(. The hand-written RestoreRowRequestBody/RestoreRowMode are the
generated RestoreRowRequest/RestoreRowMode now; scriptDisplayNames posts through
fetchJson; the tenant-group and delegation mutate helpers take a pre-encoded body.

What the typing surfaced and fixes here: canManageBootstrapTokens and `all` are
sent explicitly, AdminConfiguration goes back on the wire through
toWireAdminConfiguration (the outbound twin of fromWire…), TenantManagementSection
uses the wire TenantConfiguration instead of a local subset, nameless CPE entries
are filtered before they are sent, the session action posts
QueueSessionActionRequest, create-from-template sends { variables }.

Remaining baseline (with reason): the three rule/pattern pages, whose local rule
types diverge from the wire — B-cme.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 11:42:24 +02:00
Oliver Kieselbach 00ad230ffe mcp: request bodies typed against the generated wire types (jsonBody<T>, D-207)
The eight backend POST/PUT/PATCH bodies name their request DTO through
jsonBody<T> from client.ts — the one place a body is encoded — so a renamed
or retyped backend field fails tsc instead of surfacing as a 400 at runtime.
The apiFetch guard additionally refuses `body: JSON.stringify(` and a bare
`jsonBody(` with an empty baseline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 11:42:22 +02:00
Oliver Kieselbach 3d920a8113 shared+backend: IApiRequest marker, one RequestBody reader, every request body a Shared wire type (D-207)
Every HTTP request body the web, the MCP or the agent sends is now a flat class in
AutopilotMonitor.Shared implementing IApiRequest — the request twin of IApiResponse.
WireTypeManifestBuilder exports the implementers as a third root, so the request DTOs
reach wire-types.generated.ts on both clients; for request types a nullable property
keeps `| null` (a PATCH may send an explicit null to clear an override).

Functions read a body only through RequestBody.ReadAsync<T>/ReadOptionalAsync<T>
(System.Text.Json, ApiJsonOptions.Read = wire options + case-insensitive; empty or
malformed body = 400 envelope, never a 500) whose generic constrains on IApiRequest.
ReadNewtonsoftAsync<T> stays for the Newtonsoft-bound cluster (tenant/admin config via
the patch service's PopulateObject, rule and pattern documents) as a frozen per-file
baseline. The tenant-plan PATCH keeps its presence-sensitive JsonDocument walk with the
keys derived from PatchTenantPlanRequest by nameof (PlanPatchKeyParityTests).

35 nested request classes, 7 JObject/JsonDocument hand-walks and the anonymous
DeserializeAnonymousType bodies moved to Shared; the two identical TicketRequests are
one DownloadTicketRequest; ServerAction is no longer the request (QueueSessionActionRequest,
the stored row is built server-side); create-from-template takes { "variables": … }
instead of a bare map. Fields the endpoints default server-side are nullable now
(CPE mapping, report attachments, feedback rating on dismissal, upn for applications).

TypedRequestGuardTests: no raw deserializer under Functions/ (named exemptions: agent
routes frozen by D-200, Azure Monitor webhook, upstream/stored-column parses), the
Newtonsoft branch only in its baseline, no request type in the Functions assembly, every
DTO flat and in Shared. RequestBodyTests cover both readers, the cap and the envelope.
QueryParamGuardTests baseline shrinks by 14 (the Content-Length TryParse lives in the reader).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 11:42:03 +02:00
Oliver Kieselbach f6eac1964a chore: bump internal gitlink (both plans RELEASED, live verify, B-fq7 / B-8va)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 04:10:34 +02:00
Oliver Kieselbach e1b5b9d163 backend+web: per-user rate limits split by throttle surface — portal (public client) gets its own global budget, MCP/integrations keep override and floor (D-206)
The surface comes from the signed appidacr/azpacr claim (0 = public client = portal;
secret/certificate, app-only or missing = integration), never from a header: portal SPA,
MCP server and API share one app registration in production, so the client id cannot tell
them apart and any token holder controls headers. New AdminConfiguration field
PortalUserRateLimitRequestsPerMinute (default 600, Global Settings round-trip, wire types
regenerated); the existing user budget is relabelled MCP & integrations and keeps the tenant
override and the Pro floor; buckets are keyed per surface; ThrottleSurface is a request
dimension next to ClientSource so the classification is auditable (platform-health check 2).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 03:45:42 +02:00
Oliver Kieselbach 094833f831 backend: a Graph 401/403 on the first validator attempt invalidates the tenant token and retries fresh; the agent keeps getting 503 for a permission gap (D-205)
A freshly onboarded tenant spent 45 minutes in 503 because a second instance held a
roleless app-only token minted before the consent; InvalidateTenant never reaches other
instances. GraphAuthFailure is the shared step for all five Graph validators: attempt 1
drops the cached token and reports transient so the existing loop re-mints; attempt 2
keeps each validator's semantics (Autopilot/DeviceAssociation transient with Retry-After
120, CorporateIdentifier/CloudPc/IntuneBinding definitive) and logs a stable marker.
SecurityValidator hands the agent the largest Retry-After requested. Platform-health
check 6 and the marker catalog know both markers; the cloudpc concept doc carries the
contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 03:35:39 +02:00
Oliver Kieselbach 3579ce0add chore: bump internal gitlink (typed call sites RELEASED, verify) 2026-09-06 02:55:20 +02:00
Oliver Kieselbach 0188860d1c chore: bump internal gitlink (storage-budgets plan RELEASED)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:52:23 +02:00
Oliver Kieselbach 871a1ac059 chore: bump internal (typed call sites plan GEBAUT, D-204, envelope doc consumers, B-7sw) and .claude/commands (mcp-telemetry retries) gitlinks 2026-09-06 02:37:21 +02:00
Oliver Kieselbach 298a182fbb shared+web: backup job status and restore-row payloads join the wire contract; the web reads the generated types (audit package 5, stage 4b)
BackupJobStatus, RestoreRowPreviewResponse and RestoreRowCommitResponse were the last response payloads the
portal typed by hand: the C# classes existed in Shared but were not marked [WireContract], so the manifest
never carried them. The attribute pulls them and their enums (BackupJobKind/State, BackupOutcome,
RestoreRowDiffKind, RestoreRowCommitOutcome) into shared-manifests.json and both generated wire-type files;
the three backup pages import those and lib/api.ts drops eleven hand-written mirrors. Attribute only: no
runtime behaviour changes, no backend deploy needed.

Still hand-written in lib/api.ts, with reason: BackupManifest/BackupTableEntry (a blob passed through by
GET global/backups/{id}), BackupTriggerResponse (the 202 body of global/backups/trigger is an anonymous
object outside the typed regime) and the RestoreRow request body.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:34:11 +02:00
Oliver Kieselbach e71223507c web: customs archive reads the generated types, annotations page follows nextLink through the typed builder, dead PaginatedResponse removed (audit package 5, stage 4a)
The five hand-written customs-archive response types in lib/api.ts duplicated CustomsArchiveRunListResponse,
CustomsArchiveEntryListResponse, CustomsArchiveRunSummary, CustomsArchiveEntrySummary and
TenantOffboardingCustomsArchiveEntry from the manifest; the two pages import those. The annotations page
built its next-page URL from API_BASE_URL + nextLink instead of the scoped builder plus extractContinuation.
PaginatedResponse<T> had no importer. types/diagnostics.ts states why it stays hand-written (no wire DTO
for GET diagnostics/paths).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:28:08 +02:00
Oliver Kieselbach 02187f0f3c web: the last raw API call sites join the call layer; ratchet baseline for authenticatedFetch reaches zero (audit package 5, stage 3d)
Rules pages, apps tabs, audit, delegations, diagnosis, fleet health, geographic, health check, SLA, usage
metrics, the notification and SignalR contexts, the feedback bubble, global search and the shared hooks
now call fetchJson/fetchOk; the activation page's hand-rolled Bearer fetch (no 401 retry, no
telemetry) joins the layer too. The SignalR join/leave paths keep their visible-refusal semantics through
ApiError, the delegation acceptance explains refusals by code, the health check keeps its 403 wording,
and the local mirrors of SlaMetricsResponse and PlatformUsageMetrics give way to the generated types.

No page, component, hook or context calls authenticatedFetch directly any more. What stays is
documented per file in the baseline's PERMANENT map: the auth/me bootstrap, the SWA's own version.json,
the public stats manifest, the delete-cascade classifier's 202 body, and seven TokenExpiredError
branches where the UI must act differently from showing the message (route, rethrow, batch, retry flag).

Ratchet baseline: 39 → 0 raw fetches, 40 → 7 parses, 25 → 8 token checks (from 228 / 221 / 140).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:21:25 +02:00
Oliver Kieselbach 3e2f62daeb backend: host.json holds Azure.Identity, Microsoft.AspNetCore and Microsoft.Hosting.Lifetime at Warning
About 12 MB/day of severityLevel-1 traces without diagnostic value (MSAL verbose, StatusCodeResult,
CORS success, Application started). Application logs were Warning+ already via Function: Warning.
Measured and documented in internal/docs/backend/telemetry-ingest-shaping.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:11:44 +02:00
Oliver Kieselbach c8b8a8dbb4 web(sessions/dashboard/progress): every API call through fetchJson/fetchOk/fetchBlob (audit package 5, stage 3c)
The 33 raw authenticatedFetch sites under app/sessions, app/dashboard and app/progress are gone. Hooks
that receive addNotification as a parameter report failures through notifyApiError (same rendering as
notifyError, injected notifier); fail-soft reads that used to check res.ok read through
.catch(nullOnApiError). The delete-cascade classifier accepts the ApiError of a refused DELETE next to
the 202 Response, so the 409/503/404 branches keep their wording; the polling probe reads the status
from either. Session and event reads name their wire types (GetSessionResponse, GetSessionEventsResponse,
SearchSessionsResponse, SessionStatsResponse, the progress and rule-stats responses); the legacy
list-shaped fallbacks on the single-session read are gone. SessionTimeBreakdownDto is the generated type.

Ratchet baseline: 72 → 39 raw fetches, 67 → 40 parses, 36 → 25 token checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:09:51 +02:00
Oliver Kieselbach 9e4c5f11c5 chore: bump internal gitlink (plan verify line, trace-noise backlog idea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:00:11 +02:00
Oliver Kieselbach b16e72c10e web(settings): every API call through fetchJson/fetchOk, errors through apiErrorText (audit package 5, stage 3b)
The 46 raw authenticatedFetch sites under app/settings are gone: 20 by the transformer, the rest by hand.
The tenant config load keeps its 403 fallback to the feature-flags view through ApiError; the access
check treats a backend refusal as an outcome and only a failed round trip as transient; delegated
access, MCP users and the offboarding flows read their specialised envelopes through ApiError.code
and ApiError.body. Twenty 'Session Expired toast / else inline' catch dialects collapse to the inline
message apiErrorText already renders; local mirrors of McpUserEntry, PlanTierDefinition,
HardwareRejectedResponse and DeviceNotRegisteredResponse give way to the generated types.

Ratchet baseline: 115 → 72 raw fetches, 111 → 67 parses, 59 → 36 token checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:56:53 +02:00
Oliver Kieselbach 5cb9b0b30b backend: storage SDK budgets under the agent timeout, ingest 503 on an exhausted budget, request-token fan-outs, single-flight factory budget, ProcessStall monitor (audit F09, D-203)
- StorageClientOptions: Table/Queue 8 s per attempt × 3 attempts + capped backoffs = 25.8 s < the
  agent's 30 s upload timeout (pinned by test); Blob 60 s × 4. Every production client takes its
  options from there. No Polly over the SDK.
- StorageErrors.IsTransient(Exception): OperationCanceled / IO / HttpRequest / Aggregate-of-those
  / RequestFailed status 0; the ingest answers 503 + Retry-After instead of 500.
- The ingest still takes no caller cancellation (a batch is a unit). GetAllSessionsPageAsync
  carries the request token down to BoundedFanOut (Global Admin list); SingleFlightCache hands its
  factory a 30 s budget token and evicts on timeout (tenant ids, app-summary snapshot, session stats).
- ProcessStallMonitor: 1 s tick drift ≥ 1 s ⇒ ProcessStall event (sampling bypass) + metric with
  cause gc / threadpool / unknown from GC.GetTotalPauseDuration and thread-pool counters;
  GcPauseMs on request rows ≥ 100 ms. Measures the instance-local 5–10 s pauses seen in production.
- Gitlinks: internal (plan, D-203, concept doc, B-9hf, lesson), .claude/commands (marker rows).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:51:02 +02:00
Oliver Kieselbach c925001a8a web(admin): every API call through fetchJson/fetchOk/fetchBlob, errors through apiErrorText (audit package 5, stage 3a)
The 113 raw authenticatedFetch sites under app/admin are gone: 73 by the transformer (fetch + ok-check
+ json parse become one typed call), the rest by hand — 404-as-empty lists read through
.catch(nullOn404), the app-homing, offboard, delegated-admin and tenant-group flows read the
specialised envelope through the new ApiError.body, downloads use fetchBlob, action calls fetchOk.
Session-expired branches that only logged or set the same message are gone; apiErrorText renders
every failure with the reference.

Typing the responses surfaced eight local mirrors that had drifted from the wire types (required
fields the backend leaves optional): BlockedDevice, FeedbackEntry, DistressReport, OpsEvent,
SessionReport, IdentityBinding, UnmatchedSoftwareEntry, DailySummary and the whole
SectionPlatformUsage metric set are now aliases of the generated types, with the optional fields
handled where they render. AdminConfiguration keeps its literal-union narrowing through
fromWireAdminConfiguration instead of an unchecked parse.

Ratchet baseline: 228 → 115 raw fetches, 221 → 111 parses, 140 → 59 token checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:41:19 +02:00
Oliver Kieselbach e494334720 mcp: apiFetch<T> at every call site, one retry on 429/503 within Retry-After, typed error envelope (audit package 5, stage 2)
apiFetch takes a mandatory wire type (60 call sites name their generated DTO; the 28 as-casts
and 31 unknown results are gone) and refuses an empty or malformed 2xx body. It retries once on
429 (never the MCP quota) or 503 when Retry-After is at most 10 s — GET only, never with a
caller-supplied signal, opt-in via retry:true otherwise — and counts the retry on the tool-call
context, which withToolTelemetry writes as `retries` on the tool_call line. ApiError.parsed is
the generated ApiErrorResponse plus domain fields and carries retryAfterSeconds; toolError
renders it in the 503, envelope and rate-limit branches. The lean-projection default lives in
one helper (leanFieldSelection) instead of two copies.

apifetch-typed.guard.test.ts refuses a bare apiFetch( or apiFetch<unknown>; apifetch-retry.test.ts
pins the retry, the opt-outs and the body contract. tools-delegated fakes now answer text() with
the body they claim (the client parses text, not json).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:13:20 +02:00
Oliver Kieselbach 1554540431 web: one API call layer (fetchJson/fetchOk/fetchBlob), notifyError, ratchets with the full baseline (audit package 5, stage 1)
lib/scopedFetch.ts becomes lib/apiClient.ts: ApiError carries retryAfterSeconds (body first,
Retry-After header second), fetchJson<T> refuses empty and malformed 2xx bodies instead of
handing back undefined, sets Content-Type for string bodies, fetchOk covers action calls and
fetchBlob downloads. describeApiError renders 429/503 with the server's retry advice;
apiErrorText is the one-string form for inline error slots; apiErrorNotification folds the
"Session Expired" dialect into one place and NotificationContext.notifyError uses it.
useAuthenticatedFetch throws ApiError (correlation id preserved), drops the dead 204 branch
and onTokenExpired. dedupedFetchJson<T> for the three feature-flag readers.

lib/__tests__/apiClient.guard.test.ts ratchets raw authenticatedFetch(/dedupedAuthFetch(/
.json()/instanceof TokenExpiredError per file against a baseline that may only shrink
(a count above fails as a new site, below as a stale entry); fetchJson without a type
argument is a violation. Baseline today: 228 raw fetches, 221 parses, 140 token checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:01:41 +02:00
Oliver Kieselbach fef09690d2 web: coalesce live timeline refetches per burst instead of per SignalR signal
Agent uploads arrive as bursts of batches ~0.3 s apart with seconds of silence in
between, and every batch raises a SignalR signal. The 300 ms trailing debounce on the
session page either refetched once per batch or hid a tight burst until it paused;
session 46096bcd produced 155 full first-page reads for 261 batches in 13 minutes.

lib/burstScheduler: leading edge + trailing debounce + max wait. The first signal after
a quiet period refetches at once, later signals of a burst refetch once after the
burst's last signal, and a burst that never pauses still refetches every max-wait
(spool replay after reconnect/reboot). Fetch semantics are unchanged: every run reads
the full first page, the server stays the single source of truth, no incremental path.

Session page: trailing 300 ms, max wait 1 s. Progress portal (same pattern, two
requests per refresh): trailing 500 ms, max wait 1.5 s. scheduleFetchEvents loses its
delayMs parameter; the scheduler is created lazily on the first signal so refs stay
out of render (react-hooks/refs). Seven fake-timer tests pin the timing rules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:41:13 +02:00
Oliver Kieselbach 3a975fa74f chore: bump internal gitlink (wire-contract plan RELEASED, short-sentence lesson) 2026-09-05 23:47:00 +02:00
Oliver Kieselbach 69c6353fb1 chore: bump internal (plan gebaut, D-200/D-201/D-202, drift-guards) and .claude/commands (platform-health 422 marker) gitlinks v2.0.1450 2026-09-05 23:28:16 +02:00
Oliver Kieselbach 2b9bf3cd3f backend: one query-parameter parser (QueryParams) with a lenient and a strict contract, ratcheted (D-202)
Helpers/QueryParams replaces fourteen private days/limit parsers, the three
TryParseUtc copies, eleven identical pageSize blocks and ~50 inline TryParse sites
under Functions/ and Pagination/. Lenient (Int/IntOrNull/UtcInstant): absent, garbage
or below-minimum means the call site's default, above-maximum clamps. Strict
(TryInt/TryPageSize/TryUtcInstant): absent is null, anything else invalid is a 400
with a message that names the parameter. Integers are invariant-culture, instants are
invariant UTC (AssumeUniversal | AdjustToUniversal), whitespace counts as absent.
Defaults and ranges stay arguments at every call site.

Settled differences: the Apps windows clamp above 365 instead of falling back to 30,
days=0 or negative yields the default everywhere, the two global geo endpoints and
the session list cap days at 365, SLA months is capped at SlaMetricsService.MaxMonths
on the HTTP layer, a whitespace date means no filter on every route, the admin batch
sizes reject values above their maximum, and the three culture-dependent
DateTime.TryParse sites parse as UTC.

QueryParamGuardTests ratchets TryParse out of Functions/ and Pagination/ (baseline:
headers, raw cells, the agent version major, the se= of a stored SAS URL);
QueryParamsTests carries the cases the removed copies pinned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:26:21 +02:00
Oliver Kieselbach d99e16314b agent+backend: one telemetry wire class, 422 poison for rejected items, agent responses in the typed regime (D-200/D-201)
TelemetryItemKind moves to Shared; TelemetryItem.ToWire() maps onto TelemetryItemDto
and the uploader serialises the DTO, so both ends share one wire class. The spool line
keeps writing Kind by name (StringEnumConverter registered in TelemetryItemSerializer).

IngestTelemetryFunction.PartitionBatch routes by exact kind name and collects every
item it cannot route or parse; a non-empty rejection list answers the batch with 422 +
TelemetryItemsRejectedResponse (poison, rejectedRowKeys, reason) before anything is
persisted, logs a Warning and writes the new TelemetryItemsRejected ops event. The
agent's existing poison protocol drops exactly the named RowKeys and re-uploads the
rest; before this the ingest skipped the items, answered 200 and the agent cleared
its spool.

Contract tests from both sides: TelemetryWireContractTests (agent) freezes the real
batch body under tests/fixtures/telemetry-wire/ and parses the pinned poison body;
TelemetryWireFixtureTests (backend) dispatches every frozen body with zero rejections,
requires an arm per kind and pins the poison body.

Agent-facing responses implement IApiResponse and go through ResponseHelper, request
types carry [WireContract]; plain ingest refusals use the error envelope, the
register-session and upload-url refusals keep their DTO bodies (the agent reads
ErrorCode/Message). Manifests and generated wire types regenerated (also picks up
four types HEAD had left out of the manifest).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:10:25 +02:00
Oliver Kieselbach e4203e0f15 chore: bump .claude/commands (platform-health skill, backend-logs corrections) and internal (marker-catalog rule) gitlinks; drop stale index dual-write mention in the dependency filter comment
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:53:14 +02:00
Oliver Kieselbach 2a5366d9c3 chore: bump internal gitlink (D-198/D-199, counters concept doc, storage-hardening plan GEBAUT)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:31:33 +02:00
Oliver Kieselbach 9c9cf24ddc backend: platform counters recomputed instead of incremented, RuleStats keyed per scope and day with CAS batches (audit F06, D-198/D-199)
PlatformStats(global,current) took a GET plus a full-row upsert without ETag
from every ingest batch: increments were lost under concurrency and a writer
clobbered fields another writer had just changed. RuleStats put every tenant's
daily rule traffic into one date partition (1,459 rows on 2026-09-04), the
writer issued four serial calls per evaluated rule without ETag, and the
reader was a cross-partition scan whose 10k cap dropped the newest dates.

- D-198: TotalEnrollments / SuccessfulEnrollments / TotalEventsProcessed have
  no hot-path writer; the two-hourly recompute is the only source. Only
  IssuesDetected still increments, through TableCasRetry (ETag + field-only
  merge, AddEntity for a missing row, conflicts as StorageCasConflict). The
  tenant counters share the helper.
- D-199: RuleStats PartitionKey "{scope}_{date}", RowKey ruleId.
  RecordRuleStatsAsync merges a session's increments per rule, reads the
  partition once and writes one transaction (UpdateReplace with ETag / Add),
  retrying on 412/409. Readers are PartitionKey ranges per scope; aggregation
  and the regression radar fan out over the registered tenants; the cleanup
  deletes per scope. RuleStats joins the offboarding composite wipe.
- Legacy rows (PartitionKey = date) are read by an extra query and cleaned by
  a date range that checks every row for a bare-date key, until 2026-12-05.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:30:58 +02:00
Oliver Kieselbach ec0d8ad735 backend: blob proxy lifetime and timing, one delegation key per process, shared KEV instance, single-flight config misses (audit F11/F12)
- DiagnosticsBlobStreamer disposes the download result on every exit path
  (it was disposed only on the 413 branch), measures open and transfer
  separately and fires DiagnosticsDownloadProxied after the copy with
  Completed=true/false.
- UserDelegationKeyCache: one user delegation key per process (24 h, re-mint
  under a lock with 2 h margin or when a SAS would outlive it) instead of one
  storage round-trip per download or upload SAS.
- KevDataService is resolved as one singleton over the typed-client handler
  pipeline; the cache warmer used to hydrate a throwaway transient instance
  while the correlation service held a cold one.
- Admin and tenant configuration services single-flight concurrent cache
  misses (Lazy<Task> per key); the MemoryCache and the in-place projection
  semantics stay untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:14:56 +02:00
Oliver Kieselbach fc9e9d13e6 backend: keep slow storage successes and count CAS conflicts as a metric (audit F10)
The dependency filter dropped every successful storage call and the expected
412/409 outcomes, so storage-side latency and ETag contention were invisible
until a call failed outright (seven days of App Insights held zero storage
dependency rows).

- StorageDependencyFilterProcessor keeps a successful storage call at or above
  2 s and stamps SlowStorage=true; the operation name is on the row, the table
  name is not (the Azure SDK span carries neither URL nor table).
- StorageMetrics wraps TelemetryClient.GetMetric("StorageCasConflict",
  operation, table, outcome): pre-aggregated per minute, bypasses the worker's
  adaptive sampling, null-tolerant for tests.
- The CAS loops report conflicts: session event count, session counter
  reconcile, session store, tenant stats, MCP user/tenant usage.
- TableStorageService keeps its two-argument constructors (Moq matches by
  exact arity); DI resolves the three-argument overload.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:09:08 +02:00
Oliver Kieselbach d0cb7e66f4 backend: byte-aware table transactions and classified storage failures on the ingest (audit F07)
Every entity-group transaction was chunked by count only; 100 payload-heavy rows
(Signals with chunked PayloadJson, Events with 30k-char Message + DataJson) could
exceed the 4 MiB transaction cap, and the failure surfaced as an opaque 500. The
agent halves its batch only on 413 and treats 5xx as "replay later", so such a
batch wedged the device forever.

- TableTransactionBatcher: 100 actions, 3.5 MB per transaction, 1 MB per entity,
  never across partitions; an entity that cannot fit throws
  TableEntityTooLargeException. Used by the event, signal, transition, restore
  and rules writers; delete-only batches keep their count chunking.
- StorageErrors: one classifier for RequestFailedException (transient 408/429/
  5xx, payload-too-large by status or error code, already-exists incl. the
  Azurite 400 shape); the two IsAlreadyExistsStatus copies use it.
- Event writer: per-row fallback only for data-shaped rejections; throttling,
  outages and oversize batches propagate. Single and batched writer share one
  entity builder.
- IngestTelemetryFunction: payload too large -> 413, transient -> 503 with
  Retry-After: 5, anything else 500.
- TableStorageChunking.MaxPropertyChars is the one 30,000-char bound.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:03:07 +02:00
Oliver Kieselbach bd66480ccc chore: bump internal gitlink (remove-decision-inspector RELEASED)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 05:05:36 +02:00
Oliver Kieselbach b05f1f6bcf chore: bump internal gitlink (remove-v2-index-tables RELEASED)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 04:51:52 +02:00
Oliver Kieselbach 12ad9c9436 Remove the Decision Inspector: web subpage, three session read endpoints, renderer deps (D-197)
Web: app/sessions/inspector, Inspector button, inspectorUrl, legacy /sessions/{id}/inspector
redirect, SWA rewrites, api.inspector; drops @xyflow/react, dagre, @types/dagre.
Backend/Shared: GetSessionSignals/DecisionGraph/ReducerVerification functions, DecisionGraphBuilder,
ReducerVerifier, their DTOs and tests, the QueryBySessionAsync readers and SignalQueryLimits;
three catalog entries fewer. Signals/DecisionTransitions tables and ingest unchanged; operator
analysis reads them through MCP. Wire manifests regenerated (web + MCP copy).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 04:51:28 +02:00
Oliver Kieselbach c8ce7cdb37 backend: remove the V2 index tables and their dual-write pipeline (D-196)
The five cross-session index tables (SessionsByTerminal, SessionsByStage,
DeadEndsByReason, ClassifierVerdictsByIdLevel, SignalsByKind) were write-only
since M5.d: no endpoint, MCP tool or web page ever read them, while the
dual-write cost a serial queue send per signal/transition on the ingest path,
a 5-13 min full scan of Signals + DecisionTransitions every two hours and five
property-filter scans per session deletion (audit 2026-09-05 F05).

Removed: the tables from TableNames.All (schema hash changes, next cold start
runs a full pass without them), the telemetry-index-reconcile queue name,
producer, envelope, handler, queue function and 2h timer, the cross-partition
Timestamp scans on the signal/transition repositories, the EnableIndexDualWrite
flag (model, config mapping, admin UI, generated wire types), cascade steps
11-15 (later steps renumbered 11-18), the deletion wrappers and the offboarding
wipe entries. DiscriminatorPkProp stays as a step class so manifests written
before this change still resume.

Order of operations: deploy first, then delete the queue pair and the five
tables in the portal (B-88h). Deleting the tables before the deploy 500s every
delete preview/enqueue and poisons offboarding.

Submodules: .claude/commands (import_session.py), internal (D-196, docs, plan).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 04:27:48 +02:00
Oliver Kieselbach 56d4dd8b84 chore: bump internal gitlink (queue-trigger plan RELEASED, live verify)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 03:16:26 +02:00
Oliver Kieselbach 86fdc5aca9 backend: stateless queue consumers become [QueueTrigger] functions; webhooks go through a queue; poll loop receives one message at a time
Audit 2026-09-05 F01/F02. Analyze, VulnerabilityCorrelate, TenantAutoApprove,
IndexReconcile and ImeMsiArchive are now host-managed queue functions under
Functions/Queue bound to the DataStorage connection (identity-based, resolved
by the host): the Flex scale controller wakes an instance for a message, the
host renews the lease and owns the retry/poison ladder. The four cascade
workers with lease/CAS state stay self-polling BackgroundServices; their base
now receives one message per poll so waiting messages can no longer expire
under another worker. Terminal enrollment alerts and hardware-rejection alerts
are enqueued on notification-dispatch (channel ids only, resolved at send
time) instead of fire-and-forget sends the host could drop on scale-in.
Requires the app setting DataStorage__queueServiceUri before deploy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 02:52:37 +02:00
Oliver Kieselbach 87c5bc59dc chore: bump internal gitlink (error envelope RELEASED)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:57:06 +02:00
Oliver Kieselbach 21b30b21c2 chore: bump internal gitlink (lessons rule block)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:44:39 +02:00
Oliver Kieselbach f94f1c0c4e chore: bump internal gitlink (lesson: no Front Door for the API)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:44:02 +02:00
Oliver Kieselbach a6a9883451 chore: bump internal gitlink (error envelope + correlation docs, D-192/D-193)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:37:57 +02:00
Oliver Kieselbach 03116a97d1 mcp: one correlation id per tool call — sent to the backend, logged, shown in every error
withToolTelemetry mints a UUID per tool call and runs the handler in a
ToolCallContext (tool name + correlation id); apiFetch stamps it as
X-Correlation-ID next to X-MCP-Tool-Name, so every backend request of the call
(pagination scans included) and the MCP_TOOL_LOGGING tool_call line carry the
same id — a row-level join between the MCP log and the backend's request rows.
ApiError reads the X-Correlation-ID response header (body correlationId as
fallback), and the error handler names it in every branch — 5xx, quota 429,
structured 4xx and the unstructured 401/403/404/429 texts that had none. The
handler reads the envelope's `code` (errorCode as the pre-envelope fallback),
the quota body's `error`, and the log proxy's `upstreamCode`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:33:44 +02:00
Oliver Kieselbach 2abc78ab6d web: send X-Correlation-ID on every API call, read the error envelope, show the reference
authenticatedFetch mints one UUID per logical call (kept across the 401 retry,
where caller headers now survive too), sends it as X-Correlation-ID — the id
the backend echoes, stamps on its request row and writes into every error body
— and tracks api_request_failed {path, method, status, correlationId} for any
final status >= 400. App Insights gets enableCorsCorrelation + W3C
distributedTracingMode scoped to the API host, so portal dependency rows and
backend request rows share an operation_Id.

fetchJson/ApiError read the envelope (error, code, correlationId, hint; the
pre-envelope `message` as fallback), describeApiError renders message +
short reference, addNotification takes an optional reference the bell renders
as "Ref …"; the error boundaries show and track Next's digest. Consumers of
renamed fields follow: delete-session parser (code ?? hint, PascalCase codes),
app-homing (code ?? reason). API_ERROR_CODES comes from the manifest, and
HOME_TENANT_UNRESOLVED plus the cascade codes are `satisfies ApiErrorCode`.
InstallsTab is the first site showing the reference.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:29:54 +02:00
Oliver Kieselbach 6e54b52376 backend: migrate the 458 anonymous error bodies to the typed envelope helpers
Every non-2xx body in the Functions project now goes through ResponseHelper
(ErrorAsync / BadRequestAsync / NotFoundAsync / ForbiddenAsync /
UnauthorizedAsync / ConflictAsync / InternalServerErrorAsync): 408 sites by a
pattern-exact rewrite of the CreateResponse + WriteAsJsonAsync + return shape,
50 by hand. The eight private BadRequestAsync shadow helpers are gone; the raw
500 catch blocks use InternalServerErrorAsync (sanitized message, correlation
id, MCP hint). 429s keep Retry-After via retryAfterSeconds.

Specialised error bodies where a consumer reads more than the prefix:
AppHomingDeniedResponse (kebab reason codes now Constants.AppHomingReasonCodes,
probe result), TenantConfigPatchFailedResponse (backupId, drift),
QueryBackendLogsErrorResponse (upstreamCode, statusCode, source, hint,
upstream). Twenty-one message-first 200 bodies that the old error tolerance had
let through are typed too (MessageResponse, PreviewWhitelistActionResponse,
SuccessMessageResponse). New codes: PayloadTooLarge, UpstreamError,
UpstreamTimeout, the three trial-gate codes; the agent security path
(SecurityValidationExtensions) writes the envelope with the retry window.

TypedResponseGuardTests: WriteBaseline is empty again — the anonymous
WriteAsJsonAsync ratchet now covers error bodies with zero tolerated sites.
Manifest and generated TypeScript regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:21:01 +02:00
Oliver Kieselbach 7cef2ea361 backend: one typed error envelope (IApiErrorResponse) on every non-2xx body
Every error body now leads with { error, code, correlationId }: the generic
ApiErrorResponse (hint?, retryAfterSeconds?, operation?) plus the specialised
McpQuotaExceededResponse (reordered; `message` became `error`),
DelegatedSlotLimitReachedResponse (+correlationId) and the new
SessionDeletionRejectedResponse (replaces the anonymous delete arms). The
correlation id is stamped by the writers — ResponseHelper.ErrorAsync and the
new ApiErrorWriter for the middleware side (auth 401, policy 403/503, rate
limit 429, MCP quota 429, global exception 500) — never by call sites.
exceptionType and rateLimitInfo leave the wire; Constants.ApiErrorCodes is the
code vocabulary and is exported as the manifest section `apiErrorCodes`
(API_ERROR_CODES for web + MCP). CorrelationIdMiddleware names the readable
headers via Access-Control-Expose-Headers.

Guard: TypedResponseGuardTests no longer tolerates anonymous error shapes;
the 458 legacy sites in 149 files are frozen in WriteBaseline to melt down.
New facts: envelope-prefix reflection, ApiErrorWriter body/header pins,
exact-shape pins for the generic envelope and the changed specialised bodies.

Reverses the "error bodies stay anonymous" clause of D-043 (decision entry
follows with the docs stage).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 00:56:21 +02:00
Oliver Kieselbach 5b5ee0e8fd chore: bump internal gitlink (release verify)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:43:17 +02:00
Oliver Kieselbach 7bcc4f00b1 chore: bump internal gitlink (release verify 2026-09-04)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:42:33 +02:00
Oliver Kieselbach 34ec44e3d4 backend: duration evaluators refuse mixed timestamp provenance only
The first cut refused every reader-zone-fallback endpoint. The DeviceSetup line is
written before the agent starts and is backlog on every device, so that silenced
ANALYZE-ESP-001 for all agents without an era anchor (control sessions 2708e6b4 and
18d80f9c came back "not measurable" after the deploy). Two fallback endpoints share the
writer-zone error and cancel it; only a span mixing a fallback endpoint with an anchored
one (or with "now" for an open phase) is unmeasurable — the a7140f98 shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:34:15 +02:00
Oliver Kieselbach f607e21c13 agent+rules: Hybrid Join user affinity from the IME user token; IME backlog eras anchored by the bootstrap record
hybrid_login_pending now measures the absence of a real-user desktop after a reboot
(cancelled by the DesktopArrivalDetector or a real JoinInfo user), is never armed on
WhiteGlove devices (resume marker, Part-2 flag, .part1- archive) and reports the
JoinInfo placeholder as a fact only — on a Hybrid join the AD sign-in never replaces it.
New EntraUserAffinityDetector: armed at the real-user desktop on Hybrid devices, the
first IME-TOKEN-SUCCESS line after the desktop posts ime_user_token_acquired; ten
minutes without one post entra_user_affinity_pending with the IME token-failure codes.
ImeLogTracker exposes raw per-line token callbacks past the historic-replay gate;
HelloTracker surfaces User Device Registration 304/305 as device_registration_event.
ANALYZE-ID-004 v2.0.0 keys on those events (event_type not_exists gained a branch),
GATHER-DEVICE-006 follows, GATHER-DEVICE-007/008 collect the Entra operational log.
Constants + MCP catalog gain the five types (D-072), the guard test learns the
multi-line OnTraceEvent shape, gather_* outputs pass the MCP filter gate by prefix.

CMTrace: ImeLogEraPreScan/CmTraceEraTable anchor the backlog era that holds the
bootstrap execution record against the Deployed install marker (0.13-0.36 s in the
field), whole era or nothing, sibling files by local time; origin era-anchored.
Backend phase_duration/app_install_duration refuse a reader-zone-fallback endpoint.

Decisions D-190, D-191. Session a7140f98.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
v2.0.1449
2026-09-04 21:12:56 +02:00