* feat(backend,web): Telegram als Channel-Provider + ein einziger Dispatch-Pfad
Notifications hatten zwei Sendewege: WebhookNotificationService.SendToChannelsAsync fuer
tenant-konfigurierte Channels, und TelegramNotificationService direkt aus dem Ops-Alert-Pfad.
Ein neuer Provider haette beide anfassen muessen.
Neu: NotificationChannelDispatcher ist DIE Channel-Sende-API und routet pro Provider —
WebhookProviderType.Telegram zum Bot, alles andere als gerenderten Webhook-POST.
SendToChannelsAsync wurde aus WebhookNotificationService entfernt statt dupliziert, damit es
keinen zweiten Pfad gibt; alle sieben Call-Sites ziehen um (EventIngestProcessor, RegisterSession,
ReportDistress, SlaBreachEvaluation, AnalyzeOnEnrollmentEnd, TestWebhookNotification).
SendOpsAlertAsync/SendNotificationAsync sind virtual (Test-Seam), TelegramNotificationService
bekommt SendAlertWithResultAsync fuer die Test-Endpoints und RenderAlertText als testbaren Kern.
Telegram (=40) ist der Sonderfall: kein Webhook. Der Channel-Url traegt die CHAT-ID, der Bot-Token
gehoert der Plattform (PreviewConfig). Ein Tenant-Admin wuerde durch UNSEREN Bot senden — deshalb
ist das Gate eine Server-Regel, keine versteckte Dropdown-Option:
ValidateTelegramChannelGate sitzt in TenantConfigValidation.ValidateModel und deckt damit BEIDE
Schreibpfade ab (Full-Model-PUT und den MCP-Field-Patch ueber TenantConfigPatchService) — in der
Update-Function allein waere der Patch-Pfad daran vorbeigelaufen. Ein unveraenderter, GA-angelegter
Channel passiert das Gate, damit ein Tenant-Admin unrelated Config weiter speichern kann.
Telegram-Ziele ueberspringen SsrfGuard.ValidateWebhookUrlFormat (eine Chat-ID ist keine URL und
wuerde immer failen) und werden stattdessen als numerische Id oder @username geprueft.
Mitgeliefert, weil der Dispatcher und die Ops-Seite sie brauchen: NotificationChannel.RedactList /
RestoreRedactedList als geteilte Helfer hochgezogen (TenantConfiguration delegiert jetzt dorthin,
statt eine zweite Kopie zu pflegen), AdminConfiguration.OpsNotificationChannelsJson +
GetOpsNotificationChannels() mit Legacy-Slot-Synthese, OpsAlertRule.NotifyChannelIds/IncludePayload,
NotificationAlert.DataJson (nur GenericJsonRenderer serialisiert es, additiv in schemaVersion 1.0).
Web: der Channel-Editor liegt jetzt in components/notifications/ChannelEditor.tsx und wird von
Tenant- und Admin-Seite geteilt (showEventToggles trennt die Faelle). Telegram erscheint im
Provider-Dropdown nur fuer Global Admins (Prop aus useAuth().user?.isGlobalAdmin, Hausmuster aus
SectionAutopilotValidation); ein bereits konfigurierter Telegram-Channel bleibt fuer alle sichtbar,
damit ein Speichern ihn nicht still verwirft.
Tests: TelegramChannelProviderTests (27) — Dispatcher-Routing inkl. "Telegram erreicht NIE den
SSRF-Pfad", Chat-ID-Validierung, GA-Gate auf beiden Schreibpfaden. Mutationsgeprueft: Gate
invertiert => 5 rot, zurueck => 27 gruen. OpsNotificationChannelsTests + OpsAlertPayloadTests
decken die mitgelieferten Modell-Additionen ab. TestNotifications ist der gemeinsame inerte Stub,
der die 17 bestehenden OpsAlertDispatchService-Konstruktionen ersetzt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(backend,web): Ops-Alerts routen auf benannte Channels statt auf drei feste Slots
OpsAlertRule hatte kein Ziel. Jede matchende Regel ging an alle drei fest verdrahteten Slots in
AdminConfiguration (Telegram-ChatId / Teams-URL / Slack-URL), und nur die ERSTE matchende Regel
wurde ueberhaupt ausgewertet (FirstOrDefault). Ein Conversion-Signal an ein Sales-Team zu binden
hiess also: das Sales-Team bekommt auch jede Security-Regel.
Jetzt: AdminConfiguration.OpsNotificationChannelsJson haelt eine benannte Channel-Liste (dasselbe
NotificationChannel-Modell wie tenant-seitig, nur anderer Scope), und OpsAlertRule.NotifyChannelIds
bindet Regeln daran. Dispatch wertet ALLE matchenden Regeln aus (Where statt FirstOrDefault) und
vereinigt ihre Ziele.
Rueckwaertskompatibel ohne Migrationsjob:
- Leere Channel-Liste => GetOpsNotificationChannels() synthetisiert je konfiguriertem Legacy-Slot
einen Channel mit stabiler Id (legacy-telegram heisst "Autopilot Monitor IT Engineer") und
uebernimmt die Legacy-Enabled-Flags — ein bewusst abgeschalteter Provider bleibt abgeschaltet.
- GetAdminConfigurationFunction projiziert DIESELBE Synthese in das Read-Model, damit der Editor
zeigt, was heute tatsaechlich Alerts empfaengt, und der erste Save exakt das materialisiert.
Die Alternative waere eine zweite Kopie der Synthese-Regeln im Web gewesen, die vom Dispatcher
wegdriftet.
- Leere NotifyChannelIds = alle aktivierten Channels. Das ist, was jede heute existierende Regel
traegt, also verhaelt sich am Deploy-Tag nichts anders.
- Ids, die nicht mehr aufloesen, werden verworfen statt auf Broadcast zurueckzufallen: eine Regel,
deren einziger Channel geloescht wurde, muss NICHTS erreichen, nicht alles.
Die Legacy-Felder bleiben als Migrationsquelle stehen und werden von der Alerts-Section nicht mehr
geschrieben — aufraeumen erst, wenn produktiv Channels stehen.
Sicherheit: OpsNotificationChannelsJson ist secret-tragend und steht in der Deny-List von
RedactedCopyForReader() (ConfigRedactionTests bewacht Drift). Der Admin-PUT hat bisher keinen
Redacted-Restore — er bekommt jetzt einen fuer die Channel-Liste, damit ein zurueckgespieltes
Reader-View nicht "***REDACTED***" als Chat-ID persistiert, plus die strukturelle Validierung, die
auch die Tenant-Liste passiert.
Neuer Endpoint POST /api/global/config/test-ops-channel (GlobalAdminOnly, im Policy-Catalog
registriert). Kein neues Response-DTO: TestWebhookNotificationResponse hat identische Semantik und
wird geteilt — nur der Doc-Kommentar nennt jetzt beide Routen.
Web: die "Alert Providers"-Karte weicht der Channel-Liste (geteilter ChannelEditor, im Admin-Kontext
mit Telegram und ohne die tenant-spezifischen Event-Toggles). Jede Regel bekommt einen
Channel-Picker; leere Auswahl rendert als "all channels", nicht als Warnung — hier ist das der
gueltige Default. Die Bindungslogik liegt testbar in opsChannelRouting.ts und spiegelt
ResolveTargets: der erste explizite Pick startet vom LEEREN Set, sonst liesse sich eine
Broadcast-Regel nie verengen. handleSaveOpsAlertConfig nimmt jetzt ein Objekt statt elf
positionaler Argumente — bei einem Dutzend Feldern schreibt ein vertauschtes Paar sonst still eine
Webhook-URL in den falschen Provider-Slot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(backend): TenantTrialStarted + Event-Payload optional pro Regel
Der Trial-Start war stumm. POST config/{tenantId}/trial schrieb nur einen Audit-Entry, und der
GA-Pfad (PATCH plan) feuerte nur beim Downgrade — also war genau der Conversion-Moment, den
Sales/Support interessiert, das einzige Plan-Ereignis ohne Ops-Event.
RecordTenantTrialStartedAsync (Tenant/Info) feuert jetzt aus BEIDEN Schreibpfaden, unterschieden
per selfService-Flag, und jeweils NACH dem persistierten Save (SaveConfigurationAsync ist
fail-loud), damit kein Alert eine Trial ankuendigt, die nicht gespeichert wurde. Der GA-Pfad
haengt an der aufgezeichneten Aenderung, nicht am Request: ein reiner planTier-Edit bleibt still,
ein idempotenter Save mit demselben Datum ebenfalls, und das Beenden einer Trial (explizit null)
ist ein Downgrade und bleibt bei TenantPlanDowngraded.
Payload-Zustellung: WriteAsync reicht die serialisierten Details an DispatchAsync durch — aber ein
Channel bekommt sie nur, wenn eine Regel OpsAlertRule.IncludePayload setzt. Default AUS, bewusst:
ein Ops-Alert ist ein "da ist was, schau nach"-Signal, die meisten Payloads sind im Chat Rauschen,
und manche tragen Felder, die die Baseline nie traegt (Tenant-Domain, Contact-Adresse). Das
Informationsniveau aller bestehenden Regeln bleibt damit unveraendert.
ResolveTargets liefert deshalb zwei Gruppen. Ein Channel, den sowohl eine Payload-Regel als auch
eine schlichte Regel adressiert, landet nur in der Payload-Gruppe: eine Nachricht pro Channel, und
ein unbeteiligtes Geschwister-Match kann ein bewusstes Opt-in nicht aushebeln.
Fuer den angereicherten Alert flacht BuildAlert die Top-Level-Skalare in Facts ab (max 12, Werte
<= 256 Zeichen, Nulls und Verschachteltes raus), damit Card-Formate und der Telegram-Klartext
dieselbe Information tragen wie der GenericJson-Consumer unter "data". EventType wird immer
gesetzt — derselbe Wert steckt schon im "Event"-Fact, also keine zusaetzliche Information, nur ein
stabiler Schluessel zum Verzweigen.
Tests: TenantTrialStartedEventTests (8) — aufgezeichnete Event-Form inkl. Payload-Feldern, und die
Emissionsbedingung des GA-Pfads gegen ApplyPlanChanges gepinnt (greift bei Grant und Verlaengerung,
schweigt bei Trial-Ende, planTier-only und idempotentem Save). Die HTTP-Entry-Points bleiben
ungetestet — Suite-Konvention ist, die reinen Kerne zu testen.
Docs: OKF-Concept backend/ops-notification-channels.md. Registrierung in docs/index.md +
docs/log.md fehlt bewusst — beide Dateien tragen noch fremdes uncommittetes WIP (ops-events
Query-Surface), und ein Teil-Commit darin haette es mitgenommen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(backend,mcp): ops-events server-side field filters + generated MCP tool vocabularies
The ops-events read could only narrow by category and date window, so answering
"did any AgentEmergencyBreak fire yesterday?" meant pulling a whole category and
filtering it client-side — and for the MCP tool that client is a language model,
so the question cost a category-sized response in model context.
Ops-events filter surface (mirrors the audit-log filter pattern one-for-one):
- OpsEventQueryFilters (Shared) + OpsEventFilterRequest (query -> filters +
pagination extras) + TableOpsEventRepository.AppendFieldFilters (filters ->
OData clauses). BOTH filter builders call the appender — the single-category /
unpaged one and the per-partition fan-out builder that adds the RowKey bound —
so "no category named" cannot silently return unfiltered rows.
- minSeverity expands to an OR-set, not a range: Table Storage compares strings
lexicographically and the severity names sort Critical < Error < Info <
Warning, so `Severity ge 'Warning'` would drop exactly the alert-worthy half.
The ladder now lives in OpsEventSeverity.Rank, shared with
OpsAlertDispatchService, so "Warning and above" in a read means what it meant
when the alert rules fired.
- severity is normalised onto the canonical vocabulary and an unknown value is a
400: `eq` is case-sensitive, so "?severity=warning" would otherwise return an
empty page that reads exactly like "no such events happened". eventType stays
verbatim — stored rows outlive the vocabulary, so a retired type must remain
searchable in the historical window.
- Every filter value is folded into the continuation fingerprint and echoed on
nextLink; extras are appended AFTER the pre-existing category/tenantId
discriminators and skipped when empty, so tokens minted before the filters
existed still fingerprint identically. The tenantId drill deliberately stays
in memory (OrdinalIgnoreCase; an OData eq would lose rows on casing).
- MCP get_ops_events gains eventType / severity / minSeverity plus a `days`
shorthand resolved client-side to a concrete dateFrom (a backend-side "last N
days" would re-resolve "now" on every page and blow the token fingerprint);
an explicit bound always wins over the shorthand.
Fixed alongside: the paged cross-category fan-out skipped Platform (Azure
Monitor alerts) because its category list had drifted from OpsEventCategory —
written from day one, invisible to every paged reader, still visible on the
unpaged full-table path. The list is now OpsEventCategory.All plus a reflection
coverage test.
That drift had a structural cause, so the same round closes it for the MCP as a
whole: wire TYPES reached the MCP from day one, but vocabularies did not — types
are erased at runtime and z.enum() needs values, so every tool schema hand-typed
its own copy with nothing to compare against. The copies had drifted:
search_sessions offered 5 of 8 session statuses (no AwaitingUser, no Incomplete
— both real stored statuses), both event readers offered 4 of 6 severities (no
Debug, no Trace), get_ops_events named 6 of 7 ops categories.
- The codegen now emits src/generated/wire-vocabularies.generated.ts (VALUES:
session statuses, event severities, ops categories/severities/types,
annotation lanes/verdicts) from the manifest sections listed in
MCP_VOCABULARIES; SharedManifestParityTests exports the two new ops
vocabularies. Freshness is pinned twice — byte-equality against a fresh
codegen run in the MCP suite, and the shared-manifests-in-sync CI job.
- Tool schemas derive their z.enum() from it and descriptions interpolate the
list instead of spelling it.
- vocabulary-drift.guard.test.ts ratchets what is left: every inline z.enum([..])
must sit on a baseline naming why it has no backend owner (MCP-local knobs,
plus rule type / connection type / CVE risk, which have no C# constants class
yet). A new hand-typed list fails, and so does a stale baseline entry.
- OpsEventTypes (Shared) gives the 77 ops event types a canonical home: the
OpsEventService write sites use the constants (a raw literal now fails
OpsEventTypeDualRegisterTests, which moved from regex-scraping to reflection),
the manifest exports them, and get_resource(name="ops_event_types") serves
categories + severities + types to the model from the generated copy.
Suites: Backend 5427, MCP 629 + tsc, Web tsc + vitest 965, generator idempotent.
Deploy order: backend BEFORE the MCP redeploy — an MCP that sends filters to an
older backend gets silently unfiltered rows back and presents them as filtered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: register the ops notification channels doc in the index and log
The concept doc landed with the feature commits; its index entry and log entry
were still uncommitted in the working tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(backend): typed API response infrastructure — IApiResponse marker, wire-settings single source, widened anonymous-body ratchet (batch 0 of typed-api-contract)
IApiResponse (Shared, with SuccessMessageResponse/SuccessOnlyResponse and the
ProjectedItems/WireContract attributes for the upcoming manifest export) plus generic
OkAsync/CreatedAsync/JsonAsync<T> overloads constrained on it, so anonymous success
bodies stop compiling once the legacy object overloads go; ApiJsonOptions is now the
single source of the production wire settings (camelCase, WhenWritingNull, string
enums) used by Program.cs AND the three endpoint-test harnesses (two serialized
PascalCase-with-nulls, one JsonSerializerDefaults.Web — all could pass while the
deployed wire differed); TypedResponseGuardTests replaces OkAsyncBaselineGuardTests
with the same ratchet mechanics over two frozen baselines (44 anonymous helper sites
unchanged, plus 134 anonymous success WriteAsJsonAsync sites across 76 files —
error-shaped bodies stay anonymous by design) and a flatness/location reflection
guard (IApiResponse implementers derive from object and live in Shared, protecting
JSON key order); ApiResponseWireParityTests carries the ordinal old-vs-new
serialization proof each converted site must add
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(backend): Sessions/Annotations/Reports/Raw/Progress success responses are typed wire DTOs (batch 1 of typed-api-contract)
34 anonymous success bodies across 24 functions replaced with flat IApiResponse DTOs
in Shared (SessionApiModels/ReportApiModels + new Annotation/Raw/ProgressApiModels) —
minimal in-place transformation, call structure/status codes untouched, declaration
order == wire order, nullable exactly where a site can emit null (WhenWritingNull);
fields= projections stay IReadOnlyList<object> marked [ProjectedItems(SessionSummary/
EnrollmentEvent)], raw table rows stay PascalCase-verbatim dictionaries (no
DictionaryKeyPolicy — pinned by test), AnnotationWire and the distress-report
aggregations now build named item types instead of anonymous ones, and shared shapes
collapse to one DTO (SessionListResponse x4, SessionAnnotationListResponse x2,
SessionReportListResponse/GetSessionEventsResponse paged+unpaged via absent-null
nextLink); 54 new wire-parity facts serialize each OLD anonymous literal (independent
fixture copied from the pre-migration site) against the new DTO ordinally, with a
null case per vanishing key; ratchet baselines shrink 44->14 helper sites and
134->122 write sites; the three distress-report test files read the now-typed
aggregation items directly instead of via reflection-ToDynamic
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(backend): every anonymous API success body is a typed wire DTO — ratchet baselines empty, legacy object overloads deleted (batches 2-5 of typed-api-contract)
the remaining 141 anonymous success sites (Admin 36, Rules 24, Config 15, Graph 5,
Metrics 16, Vulnerability 17, Infrastructure/Global/Notifications/Bootstrap/
Diagnostics 20 incl. the 429 quota body in McpQuotaEnforcementMiddleware, plus the
14 helper-call sites) now return flat IApiResponse DTOs from Shared (new Admin/
Rules/Config/Graph/Metrics/Vulnerability/Infrastructure/Notifications/Diagnostics
ApiModels; GeographicApiModels extended) — same minimal in-place transformation as
batch 1: declaration order == wire order, nullable exactly where a site can emit
null, dual success/failure sites keep their status-code logic and reuse
SuccessMessageResponse, MCP-critical key names (records/summaries/inventory)
pinned; wire item shapes that were function-local (CustomsArchive Run/EntrySummary,
CpeMappingDto, UnmatchedSoftwareAgg, AutoResolve items, LogPatternTestResult,
CveExposureSummary/SeverityBreakdown/TopCve — records rewritten as flat classes,
netstandard2.0 has no IsExternalInit) moved to Shared so the manifest export sees
them; payload types that must stay in the Functions assembly (RuleDryRun,
PreviewWhitelistEntity, GlobalAdminEntity, HealthCheck, GlobalNotificationDto,
Backfill/Reclassification/MaintenanceResult, TenantAdminEntity,
TenantConfigFieldSchema) ride as object slots — wire-identical via runtime-type
serialization, proven by the parity facts; 126 new wire-parity facts total
(Admin 26, Rules 12, Config 17, Graph 7, Metrics 16, Vulnerability 19,
Infrastructure 21 + Dictionary-key-verbatim and null-value pins); both
TypedResponseGuard baselines are now EMPTY regression guards and the untyped
OkAsync/CreatedAsync(object) overloads are gone, so a new anonymous success body
no longer compiles through the helper and fails the guard through raw
WriteAsJsonAsync; deliberately untouched: error bodies (one anonymous shape by
design), auth/me (AuthDecisionResult.Body slot shared with error bodies, tests
reflect over it), CheckMcpAccess (conditional dictionary keys), raw-byte streamers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(contract): C#→TS wire codegen — shared-manifests v2 exports the full response-type graph as generated TypeScript (batch 6 of typed-api-contract)
SharedManifestParityTests now writes schemaVersion 2: the v1 catalog sections stay
byte-compatible (sectionFieldMap/signalrMessages/sharedManifestChecks untouched),
the consumer-less sessionSummary section is gone, and a new types section carries
every wire type — roots are all IApiResponse implementers plus [WireContract]-marked
types (SlaViolatorSession, ImeVersionHistoryEntry), transitively closed by the new
WireTypeManifestBuilder over refs, collections, dictionaries, enums and
[ProjectedItems] item types (208 types). Fields honour [JsonPropertyName]/
[JsonIgnore], get-only members export readonly, a nullable slot exports optional
(WhenWritingNull omits the KEY — explicit null survives only inside collections and
dictionary values, so the nullable flag lives on nested descriptors only), field
order follows STJ's derived-before-base serialization for the one legacy inherited
wire type (LocationSessionRow : SessionSummary), and unmappable shapes (non-string
dictionary keys, foreign BCL classes) fail the build instead of degrading to
unknown. C# <summary> texts travel as JSDoc: Shared builds XML docs now (doc-quality
warnings CS1591/1573/1574/1584/0419 suppressed — crefs to foreign assemblies cannot
resolve from Shared; CS1570 malformed-XML stays an error, which surfaced a raw
0x01 control char and an unescaped ampersand in existing comments).
generate-shared-manifest-types.js emits utils/wire-types.generated.ts (interfaces +
string-union enums, 2.7k lines) next to the lean literal module, npm run
generate:manifests wraps it, the vitest freshness test pins BOTH outputs, and a new
shared-manifests-in-sync CI job (guardrails-in-sync pattern) fails on drift
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): hand-written response types re-export the generated wire interfaces (batch 7+8 of typed-api-contract)
types/session.ts, types/enrollment.ts and types/adminConfig.ts are re-exports over
utils/wire-types.generated.ts (Session = SessionSummary alias for the 25+ barrel
importers; adminConfig keeps only the excessiveEventAutoActionMode literal-union
narrowing and the client-side OpsAlertRule view) — the generated types are never
weakened, consumers adapt: durationSeconds is now optional as on the wire (int? —
consumers guard with ?? 0, which also retires the latent NaN-minutes display on
in-progress sessions), status is the SessionStatus union (fixtures on canonical
values; the generic string-sort test keeps its own fixture type), required bools
break hand-built fixtures once → new test/factories.ts makeSession/makeEvent
(Object.assign, not spread — Partial spread re-widens and tests must force
data: undefined); the string|null lifecycle branches were already truthiness-based.
Duplicate local types deleted: geographic-performance now uses the real
LocationSessionRow wire type (incl. DO fields), GlobalSearch the generated
QuickSearchResult, sla/page.tsx the generated SlaViolatorSession (status stays a
number — C# int, rendered via statusLabels); sessionExportUtils aliases
SessionExportEvent = EnrollmentEvent and derives RuleResultCsvData via Pick, CSV
output unchanged; the three structural ReplayInputEvent clones (InstallEvent,
ScriptInputEvent, DownloadEvent) are aliases now. docs: drift-guards.md replaces the
shrink-only-OkAsync section with the typed-wire-DTO contract, index.md/log.md
updated. tsc 0, vitest 957, eslint 0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tests): SlaMetricsServiceTests anchor sessions inside the current ISO week — the 'now - 1h' fixture crossed into LAST week during the first hour of every Monday (UTC), emptying CurrentWeek and failing the three snapshot asserts (CI on Mon 2026-08-31 00:20Z; pre-existing Monday-morning time bomb, unrelated to the DTO migration); the anchor is now clamped to the week's Monday 00:00, proven green inside the same failure window
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A redeploy at an unchanged code sha (the documented way to refresh the
baked-in search_docs corpus) built an image with the identical tag;
az containerapp update saw the same image reference and created no
revision, so the verify step timed out against the old build (run
33215583943). Tag = sha-run_number; build commit/version stay separate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The scripts behind download.autopilotmonitor.com/agent/ rotated only on a
publish_as_stable run of build-agent.yml, so a bootstrap fix sat unpublished
until the next agent cutover -- while the WDP bootstrap MSI downloads the
published copy on every enrolling device.
Publish-BootstrapScripts.ps1 becomes the single owner of the chain: dev render
with anchor check, $ScriptVersion bump guard, uploads (eu + fail-soft legacy
mirror, no-cache), the two version oracles, and verification through the alias.
publish-scripts.yml calls it on every push to main touching the sources; the
publish_as_stable branch of build-agent.yml calls the same code, so a release
still lands a consistent set.
Blobs are published in a deterministic CRLF/UTF-8-without-BOM form. Git
normalises line endings on checkout, so raw working-copy bytes would differ per
environment and make every byte comparison -- guard and verify alike -- depend
on where the workflow ran. The form reproduces the currently published bytes
exactly.
The Pester (PS 5.1) and ASCII gates move into the reusable
bootstrap-script-gates.yml and now block the upload; pester.yml keeps the
pull-request view and drops its push trigger. AdminConfiguration
.LatestBootstrapV2ScriptVersion gets exactly one writer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WIX0103 on the first run: Invoke-BootstrapDownload.ps1 not found because
wix build resolves File/@Source relative to the working directory, not
the wxs location. Matches the local build documented in Package.wxs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
workflow_dispatch-only, mirroring the agent release contract: validate
the runner script (PS parse + ASCII-only, it runs as SYSTEM on every
enrolling device), wix 5 build, upload versioned + stable blob names to
the agent container (no-cache, no legacy mirror - the MSI channel never
existed there), HEAD-verify the public alias serves the fresh bytes,
Sigstore provenance attestation. MSI version stays source-controlled in
Package.wxs - bumped to 1.0.2 (runner: encoding fix + bootstrap-msi.log
rename). Customer download:
https://download.autopilotmonitor.com/agent/AutopilotMonitor-Bootstrap.msi
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
P5.x-A: Constants.SignalRMessages catalog (13 names) + SignalRGroupHelper.HubName;
all backend send sites and web subscriptions typed against the shared-manifest
union (SignalRContext.on/off) — unknown names are tsc errors. Dead eventStream
session-payload handling removed from diagnosis page.
P5.x-B: RowKeyCodec as the single inverted-tick encoder (persisted widths pinned,
UserActivity D20 legacy); DeletionRowDump.Table on tombstone rows with
DeletionTombstoneTables.Resolve replacing the duplicated Contains('_') heuristic
(legacy fallback retained for pre-field manifests).
P7.1: PATCH config/{tenantId}/fields flipped to TenantAdminOrGA (phase 2);
Settings sections save via per-section diff PATCH from sectionFieldMap.ts —
the ~39-field stale round-trip vector is gone; legacy webhook clearing now only
on the notifications save; agent-settings reset no longer discards contact edits.
P7.2/7.3: useGlobalAdminScope is a projection over useAggregatedAdminScope
(resolveConcreteScopeView, test-pinned); scopedApi builders own the tenant/global
endpoint-pair routing for all 11 former hand-rolled call sites; scopedFetch adds
the shared JSON fetch layer.
P7.4: navVisibility extraction + roles x nav-config matrix and nav<->route-guard
consistency test with a ratchet for the two known pre-existing mismatches
(Operator and GlobalReader vs /settings — user decision pending).
P7.5: web build stamp public/version.json (prebuild, GITHUB_SHA), SWA no-store
route, deploy-web verify poll + versions/web.json manifest, Portal Build card.
P7.6: OkAsyncBaselineGuardTests freezes the 44 anonymous-response sites
(shrink-only ratchet).
Verified: dotnet build 0W/0E, Functions.Tests 4123, DecisionCore 519, V2.Core
2206, SummaryDialog 44; tsc 0, ESLint 0, vitest 762/762; mutation proofs for all
new drift nets. Docs: docs/drift-guards.md + versioning.md web row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- adminConfig.ts: drop retired V1 latestAgent*/customSettings ghost fields,
add the 8 backend fields the interface was missing
- ops alerts: register the Sla category (BreachNotification/ConsecutiveFailures/
EvaluationCompleted) + OrphanEventsCleaned so operators can route them
- analyze rules: expose app_install_duration in the builder SOURCES;
fix stale XML docs (event_data_array, count_per_group_gte/in/not_in)
- UploadIntervalSeconds: collapse 5 diverged defaults (10 vs 30) onto
Constants.DefaultUploadIntervalSeconds = 10 (the served value)
- combine-rules workflow: commit ALL three combine.js outputs and include
rules/guardrails.json in the path filters
- api.ts apps sessions builders: move trailing optional strings into an
options object (adjacent optional strings swallowed a forgotten tenantId)
- SummaryDialog: canonical phase display names ("Apps (Device)")
- mcp-server.bicep: fix pre-EU-cutover hostname in deploy docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 99-warning legacy backlog from the React 19 / eslint-plugin-react-hooks
v6 upgrade is fully burned down — CI now fails on any new warning. The
compiler rules stay demoted to "warn" so local dev output remains readable;
the eslint config comments now point to the established fix patterns
instead of the retired ratchet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Node 20 actions are deprecated on GitHub runners; actions/cache@v4 was
the last remaining action targeting it (MCP server tests job warning).
v6 runs on node24 like the rest of the workflow actions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The docs page and the admin UI both told customers to run
Grant-AutopilotMonitorAddOn.ps1, but the script was never published
anywhere -- it only exists in this private repo. Publish it on the
download alias next to the agent artifacts and reference it:
- build-agent.yml: upload the script on publish_as_stable runs
- admin UI: copied grant command now starts with an irm -OutFile
download line; prose links the script directly
- utils/config.ts: new AGENT_DOWNLOAD_URL registry constant
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The vector-search tests tokenize with the real Xenova/all-MiniLM-L6-v2
model. transformers.js caches it under node_modules by default, which npm ci
recreates on every run — so each CI run re-downloaded the tokenizer/weights
from huggingface.co, and a rate-limited response (429) failed the whole MCP
job (run 31252869254; unrelated to the commit under test). Persist the model
via actions/cache and point HF_CACHE_DIR (already honored by
vector-search-provider.ts) at it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wrap ad-hoc fetch closures in useCallback and complete effect deps
(SectionPlatformUsage, usage-metrics, diagnosis, TenantConfigContext,
SignalRContext, dashboard, fleet-health, useProgressSignalR). Convert
sla's initialLoad to a ref so the fresh-once flag can't retrigger the
fetch effect. Document the three intentional live-ref bumps in unmount
cleanups (cancellation counters) with justified disables.
Also fixes a real stale-closure bug: TenantConfigContext's save callback
serialized webhookNotifyOnHardwareRejection without depending on it, so
toggling only that setting saved the old value.
The 3 react-compiler preserve-manual-memoization infos remain (compiler
bailouts, not defects) - cap ratcheted to the exact current count of 99.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Refactor Install-AutopilotMonitor-dev.ps1 (v2.3-dev) into functions
(Get-CloudPcModel, Get-RealUserProfilePaths, Get-RelaxDecision,
Get-BootstrapDecision with reason codes) with an Invoke-Bootstrap main and
a dot-source entry guard, so the Pester suite can load the logic without
executing the bootstrap. Behaviour when run directly is unchanged.
Fixes a refactoring leak found by smoke-run: Write-Log used Write-Output,
which turned function return values into arrays and swallowed console
output; now Write-Host, guarded by a dedicated logging-purity test.
Add Install-AutopilotMonitor-dev.Tests.ps1 (25 tests: Cloud PC detection,
relax scenarios incl. the W365 field case, guards 1-5, profile collection
with WMI failure path) and .github/workflows/pester.yml running the suite
under Windows PowerShell 5.1 (IME runtime) plus an ASCII guard over all
bootstrap scripts. Tests pass 25/25 under PS 5.1 and pwsh 7.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
True parallel operation, no forced re-consent: existing tenants keep signing
in via the legacy app (1a400946), new tenants land on the C4A8 app (886ab5e2).
Model "default legacy, funnel primary, learn in the background":
- Web: am_auth_app flag -> NEXT_PUBLIC_ENTRA_DEFAULT_APP -> primary; get-started
CTA hands over with ?authapp=primary; auth/me homedApp updates the flag so an
operator tenant flip takes effect on each user's NEXT login; AADSTS90094/65001
one-shot cross-app retry, AADSTS65004 -> failed screen with previous-app link;
am_login_attempt pins redirect returns to the app that started them.
- Backend: TenantConfiguration.HomedAppClientId (null = legacy, GA-only flip via
PUT config) + LastAuthClientId/Since observability; EntraAppRegistry resolves
the acting app per tenant (no-op until the legacy config pair is set); Graph
client-credentials, admin-consent URLs and the surfaced client id follow the
homed app (app-scoped cache keys); JwtBearer [Authorize] pipeline now shares
the middleware audience builders (was single-audience - would 401 legacy
tokens after the swap); middleware trusts EntraId:LegacyClientId implicitly.
- well-known association file now lists both app ids (publisher domain);
deploy-web passes NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID / _DEFAULT_APP;
mcp-server.bicep default client id -> C4A8 app.
Backend xUnit 3730/3730, vitest 625/625 (13 new), tsc/ESLint clean, next build ok.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wasAuthenticated was written AND read during render (the only such
case). Historical per-mount state belongs in useState, adjusted during
render via the guarded official pattern — behavior-identical: the value
is only consulted in !isAuthenticated renders, which can only follow a
render that already set it. Auth-gate control flow (auto re-login,
loading early-return, post-login return URL) untouched.
react-hooks/refs: 26 -> 0. Lint ratchet 115.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nine render-phase mirror writes replaced: scope/filter/pagination
mirrors sync post-commit via useLatest; consuming callbacks list the
stable refs in their deps. Drops two write-only refs
(joinGlobalAdminsRef, loadingMoreRef) that nothing read. Imperative
refs (fetchGen/fetchLock/loadAllToken/submittedFilter) untouched.
Lint ratchet 119.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven scope mirror refs sync post-commit via useLatest instead of
render-phase writes; fetchStats lists the stable refs it reads in its
deps. Lint ratchet 128.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four scope mirror refs (routeGlobal, selectedTenantId, tenantId, days)
now sync post-commit via useLatest instead of render-phase writes.
fetchData lists the stable refs it reads in its deps. Lint ratchet
135.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Render-phase ref mirror writes tear under concurrent rendering
(react-hooks/refs). New hooks/useLatest.ts syncs the mirror in a passive
effect instead; refs from custom hooks are not linter-known-stable, so
consumers list them in dep arrays (identity is stable — no re-creation).
Lint ratchet 141 -> 139.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stable Install-AutopilotMonitor.ps1 is now the previously field-tested
-dev variant (v2.2): agent download via the Front Door alias
https://download.autopilotmonitor.com/agent/ instead of the legacy blob
host, mandatory SHA-256 manifest verification (Content-MD5 fallback
removed), and the OOBE-relax exemption for the Windows Backup for
Organizations restore case. Test-ShouldBootstrapAgent.ps1 mirrors the
same guard logic; the separate -dev.ps1 files are gone.
CI dev-render anchor literals in build-agent.yml updated to the alias
URLs (stable + dev channel) so the literal-substitution check keeps
passing; local build.ps1 anchors updated too (gitignored).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
First burn-down batch of the React-Compiler hooks warnings (163 -> 141,
CI ratchet lowered to match).
static-components (14, real remount bugs): SortIcon (geographic-
performance), SortIndicator (VulnerabilityReportSection) and SearchIcon/
ResultsDropdown (GlobalSearch) were defined inside render — a new
component type every render, so React unmounted and rebuilt their
subtrees each time (GlobalSearch tore down its dropdown on every
keystroke). All hoisted to module level with explicit props.
purity (6): Date.now() during render replaced with mount-time clocks
(useState lazy init) for the date-input caps (Maintenance sections) and
trial-expiry checks (TenantManagement, SectionTenantConfigReport);
FeedbackSection refreshes its clock per fetch so "Xm ago" labels track
the data; DownloadProgress falls back to NaN instead of wall-clock for
unparseable event timestamps (rate calc already guards via
Number.isFinite).
immutability (5): fetch functions were declared below the effects that
call them — moved above (diagnosis, fleet-health, DistressReports).
This unblocked deeper compiler analysis, surfacing 3 latent
set-state-in-effect findings in those files (93 -> 96); they belong to
the set-state category and stay for its own batch.
Verified: eslint 0 errors / 141 warnings (gate exits 0), tsc clean,
vitest 611/611, static export builds.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(web): typed routes — broken internal links are now compile errors
typedRoutes: true (stable in Next 16; `next typegen` already runs in CI
before tsc since #114). A typo'd <Link>/router.push target now fails the
build instead of 404ing at runtime — the failure class behind the recent
RSC/routing incidents.
- lib/routes.ts: registry helpers return `Route`; withQuery validates the
base path literal. New: deviceBlockUrl (OpsEventsSection previously
hand-built /admin/security/device-block against the registry rule),
route() to compile-check dynamic-section literals (nav configs,
section index redirects), trustedRoute() as the single named seam for
runtime-data hrefs (backend-emitted notification deep links, persisted
post-login return URL).
- Nav types (NavItem, ExpandableSubItem, PageSectionItem) carry Route
hrefs — every sidebar/navbar entry is compile-checked.
- ClientRedirect is generic so dynamic-section literals infer.
Verified: tsc clean; negative test (typo'd static + dynamic routes)
fails compilation as intended; 611/611 vitest; static export builds
with an unchanged route list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(web): lint findings to zero errors + CI lint gate
Package deal — fixes only land together with the gate, otherwise they
drift right back. CI web job now runs `eslint . --max-warnings 163`:
errors fail outright, the warning cap is a ratchet (lower it when fixing
warnings, never raise it).
Fixed to zero (mechanical categories, behavior unchanged):
- no-unused-vars (49): dead imports/locals removed; where a prop is part
of a live call-site contract, only the destructured binding dropped
- no-explicit-any (29): real structural types for event payloads
(EnrollmentEvent.data et al -> Record<string, unknown> + per-event
interfaces), SignalR passthroughs typed via the library's own
signatures; five consumer files got typing-only follow-through
- no-unescaped-entities (15), prefer-const, jsx-no-comment-textnodes
- no-require-imports (5): scripts/**/*.js override — Node CJS scripts,
require() is correct there
- one stale eslint-disable directive removed
Deliberately NOT fixed inline: the React-Compiler hooks rules new in
eslint-plugin-react-hooks v6 (set-state-in-effect 93, refs 26,
static-components 14, purity 6, immutability 5,
preserve-manual-memoization 3) are demoted to warn in eslint.config.mjs —
each is a per-site behavioral refactor in auth/SignalR-adjacent code, to
be burned down incrementally under the ratchet.
Verified: eslint 0 errors / 163 warnings (gate command exits 0), tsc
clean, vitest 611/611, static export builds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(web): bump to next 16, react 19, msal-browser 4, msal-react 3
react 19.2 aligns the declared dependency with the React the app router
has been running all along (Next-vendored 19 canary). msal-react 3.0.29
is the React-19-compatible bridge that only needs msal-browser 4 —
deliberately NOT msal v5, whose COOP redirect bridge requires new
bridge pages per origin plus app-registration changes (separate
project). The react-leaflet peer overrides are gone: react 19
satisfies react-leaflet 5 natively.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): react 19 / msal v4 / next 16 code adaptations
- msalConfig: allowNativeBroker -> allowPlatformBroker (v4 rename);
storeAuthStateInCookie stays (deprecated in v4, removed only in v5)
- zero-arg useRef() removed in @types/react 19: GlobalSearch (2x),
dashboard autoLoadTimerRef
- React.MutableRefObject -> React.RefObject in 5 session/progress hooks
(all refs are mutable in 19 types)
- layout: data-scroll-behavior="smooth" opt-in so route changes keep
jumping instead of animating (Next 16 stopped suppressing CSS
smooth-scroll during navigation)
- next.config: analyzer comment - Turbopack default build skips the
webpack-only analyzer, use --webpack for ANALYZE runs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(web): untrack Next-managed next-env.d.ts, generate types in CI
Next 16 rewrites next-env.d.ts on every dev/build run and the routes
import inside flips between .next/types and .next/dev/types (vercel/
next.js#86001) - official guidance is to gitignore it. CI now runs
`next typegen` before tsc so the type stubs exist on a fresh checkout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(web): migrate lint script to eslint CLI (next lint removed in 16)
Minimal flat config from eslint-config-next@16 (core-web-vitals +
typescript) with build-output ignores. Lint stays out of CI as before;
the 264 pre-existing findings are untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The pinned Azure/static-web-apps-deploy@v1 has no is_static_export input
(warning only — Oryx detected the export and validated out/ regardless).
The real failure was config validation: with "trailingSlash": "auto" SWA
normalizes /x and /x/ to the same route key, so the defensive
trailing-slash duplicates were rejected as duplicate rules. One entry per
route covers both forms under auto.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
output:'export' + trailingSlash: the SWA now serves plain static files; the
managed SSR runtime — the portal's last cold-start source ("Backend call
failure" when a cold start exceeded SWA's 45s limit) — no longer exists.
- Detail routes are query-string based (/sessions?id=, /sessions/inspector?id=,
/diagnosis?id=, /apps/detail?name=, /admin/backups/detail?id=,
/admin/customs-archive/detail?tenantId=&rowKey=). Single-definition builders
in lib/routes.ts; all 21 link sites migrated, duplicate sessionHref helpers
consolidated. Suspense boundaries added everywhere useSearchParams drives
page identity (hard export-build errors otherwise).
- 9 [section] families prerender via server shells (generateStaticParams from
the *NavSections registries, dynamicParams=false, client bodies extracted to
SectionClient) — 37 pages; server redirect() index pages became
ClientRedirect shells. robots/sitemap/og-image routes force-static.
- Legacy PATH-shaped deep links are preserved FOREVER (sent Teams/Slack/
webhook notifications cannot be edited): SWA wildcard rewrites serve the
family page and the globally mounted LegacyPathRedirect rewrites to the
canonical query URL, preserving query + #event hashes (mapping pinned by
lib/__tests__/legacyPathRedirect.test.ts).
- middleware.ts deleted → client-side HostRoutingGuard (prefix list moved to
lib/hostRouting.ts). Legacy /go/[code] route + bootstrapValidation deleted —
BootstrapSessions table verified free of active pre-cutover sessions.
- staticwebapp.config.json now carries production routing: 19 docs/marketing
redirects (order-sensitive), legacy rewrites (exact before wildcard), 404
override, all security headers incl. literalized prod CSP (no unsafe-eval),
Content-Type routes for the extension-less og images. Guarded by the new
utils/__tests__/swaConfig.guard.test.ts. Maintenance mode is now a
first-routes-entry insert, never a whole-file swap (README rewritten).
- Backend notification deep links go through Constants.PortalSessionUrl
(query shape, escaped, pinned); 3 producers migrated.
- deploy-web.yml uploads out/ with is_static_export.
Export build: 79 static pages, no go/ output. Web: tsc clean, vitest 53
files green (618 incl. new guards; 2 deleted legacy suites). Backend:
solution build clean, 3622/3622 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Azure/static-web-apps-deploy@v1 has no `skip_deploy_on_missing_secrets`
input (confirmed against its action.yml), so passing it did nothing but
emit an "Unexpected input(s)" warning — the intended "skip green on
Dependabot PRs" behaviour was never actually in effect.
Replace it with the documented mechanism: surface the deploy token as a
job-level env var and gate the deploy/close step with `if: env.DEPLOY_TOKEN
!= ''` (secrets cannot be referenced directly in an `if:`). On Dependabot
PRs the token is absent, so the step skips and the job stays green; real
deploys on main and internal PRs still run. Applied to both jobs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Node 20 reached EOL (2026-04-30); Node 22 is the current maintenance LTS
and already the runtime the MCP container ships (node:22-slim). Straighten
the version skew so per-component CI major == deploy runtime == engines floor:
- ci.yml: web, mcp and guardrails setup-node 20 -> 22
- combine-rules.yml: setup-node 20 -> 22
- mcp package.json: add engines.node ">=22" (matches Dockerfile)
- web package.json: add engines.node ">=22" (first explicit pin; SWA/Oryx
no longer picks a Node version implicitly)
engine-strict stays off (no .npmrc), so the pins are advisory and cannot
hard-break local installs on other Node versions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first release after the download-alias migration exposed Front Door
caching as a correctness hazard: the alias kept serving the previous
version.json (X-Cache: TCP_REMOTE_HIT) after the origin had rotated to
2.0.1370, pinning the portal's version badge to the old build. The dangerous
variant is worse than a stale badge -- manifest and ZIP are independent cache
entries, so a stale manifest paired with a fresh ZIP fails the bootstrap's
SHA-256 integrity check and the install dies.
Route caching on /agent/* has been disabled (infra side); this commit is the
defense-in-depth half: every upload now carries x-ms-blob-cache-control
no-cache -- the CI Send-Blob (eu and legacy mirror alike), the local build.ps1
counterpart (gitignored, changed in lockstep), and the versions/*.json
manifest publishes. Uniform no-cache rather than an immutable/mutable split
because every blob in the agent container rotates in place, including the
per-line AutopilotMonitor-Agent-v2.zip -- nothing there is safely cacheable.
Existing blobs get the header with the next release's re-upload.
Log/docs updated in url-registry.md; no code paths changed, YAML and ps1
parse-checked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All three components now report <major>.<minor>.<build> plus a 7-char commit
and a build timestamp. The backend had been pinned at a hand-written 1.0.0
since inception and starts at 1.5.1000; the MCP server's package.json version
was bumped by hand and starts at 1.5.300, and it now reports its own commit on
/health instead of only the documentation bundle's.
The build number keeps coming from a counter blob reserved before the build
with ETag-CAS, but there is now one implementation instead of two copies:
.github/scripts/Request-BuildNumber.ps1, called by all three workflows and by
the local build.ps1. It lives under .github/ because scripts/Deployment/ is
gitignored (those scripts carry SAS tokens) and would therefore be absent from
a CI checkout; scripts/Build/ and scripts/Release/ are shadowed by the
[Bb]uild/ and [Rr]elease/ artifact ignores.
The counter cannot be replaced by the published manifest, which is the obvious
simplification to reach for: version.json is written only on stable publish, so
two consecutive dev builds would derive the same number and the second would
overwrite the first's artifact. git rev-list --count fails the same way, two
builds of one commit collide.
Both deploy workflows now poll their health endpoint until it reports exactly
the version and commit just deployed, and publish versions/<component>.json
only then. A green deploy step alone means the package was accepted, not that
it is being served -- so a manifest that disagrees with the live endpoint now
means the running instance drifted (rollback, stale revision) rather than a
pipeline that stopped halfway. The agent manifest gains the same commit and
buildUtc fields additively; the bootstrap reads it by field name, so older
installers ignore them, and sha256/bootstrapVersion stay untouched.
Requires the versions container plus AZURE_BLOB_VERSIONS_SAS_TOKEN and the two
seeded counters (backend 999, mcp 299) before the first deploy run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CodeQL analyzes C# and TypeScript, so a commit touching only docs or markdown
cannot change its result - it spent ~10 minutes (8:50 for C#, 1:32 for
TypeScript) re-reporting an unchanged outcome.
paths-ignore on push and pull_request only. The weekly cron stays unfiltered:
it exists to catch advisories published against code that did not change, and
it doubles as the backstop should the filter ever skip too much.
A commit touching docs AND code still runs - GitHub skips only when every
changed path matches the filter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The header claimed it "runs the full test suite" - it also typechecks the web
and MCP projects and fails on drift between rules/guardrails.json and the
generated rule files. It also called itself "the gate that decides whether a
change is allowed to be correct", which was both garbled and false: main is
deliberately unprotected, so a red run reports rather than blocks.
Comment only, no behaviour change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bucket B (real fixes) + Bucket A (config) of the CodeQL alert triage.
Log-forging (cs/log-forging, 8 sinks): add Helpers/LogSanitizer that strips
CR/LF and other control characters, and apply it to the logged copy of every
user-controlled value in AuthenticationMiddleware, PolicyEnforcementMiddleware
(Invoke + DecideAsync), McpQuotaEnforcementMiddleware and TableUserUsageRepository.
Only the logged value is sanitized — routing, authorization and storage keys are
untouched, so tracing fidelity for legitimate values (paths, UPNs, GUIDs) is
preserved. Structured logging already isolated the fields; this closes the CRLF
vector as defense-in-depth and clears the query.
Three genuine JS/TS fixes:
- guardValidation.ts: replace the no-op `.replace(/%/g, "%")` with a real
escapeRegExp() — the author intended to escape; this also closes the latent
metacharacter gap (js/identity-replacement).
- access-guard.ts: use a `%s` format argument instead of interpolating the UPN
into the console.error format string (js/tainted-format-string).
- docs-corpus.ts: strip HTML tags with a loop-until-stable stripHtmlTags() so the
sanitizer is robust and recognized as complete (js/incomplete-multi-char-sanitization).
CodeQL config: filter the two queries that fire structurally on this codebase,
where the flagged behavior is the feature — cs/exposure-of-sensitive-information
(audit/notification logs legitimately record the actor UPN / recipient email) and
cs/cleartext-storage-of-sensitive-information (rate limiters key on the trusted
client IP). Default suites stay active; genuine one-off false positives from other
queries are dismissed individually so those queries stay sharp.
Tests: Backend xUnit 3110 (+9 LogSanitizer), MCP vitest 19 (+1 tag-strip),
Web vitest 49, all tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dependabot PRs run without repository secrets, so AZURE_SWA_DEPLOY_TOKEN is
empty and Azure/static-web-apps-deploy exits with an error, painting a red
"Build and Deploy Job" check on every web-touching Dependabot PR. Setting
skip_deploy_on_missing_secrets makes the action skip the deploy (green) when
the token is missing; real pushes to main still carry the token and deploy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LogParserCollector and EventLogCollector called no guard at all, while
File/Json/Xml did — and rules/gather-rules.md already promised customers
that log-parser paths were allowlisted. A tenant admin could author a
logparser rule against C:\Users via POST /api/rules/gather and have up to
1000 lines uploaded as telemetry, bypassing the hard C:\Users block the
same page guarantees; eventlog could read any channel, Security included.
The backend validates no collectorType, target, or parameters, so the
agent is the only place this can be enforced.
Both now guard. logparser cannot check rule.Target directly (GetFullPath
throws on a wildcard), so it checks the directory for wildcard targets and
re-checks each resolved path. Event log channels get an allowlist matched
on a "/" boundary. Hard-blocked channels live in a C# constant rather than
guardrails.json: a parse error drops the allowlists to empty, which is
fail-closed, but would have lifted a block sourced from the same file.
Also fixes an inert timeout in CommandCollector: ReadToEnd() blocks until
the pipe closes, so WaitForExit(30000) was only reached once the process
had already exited and a hung command pinned its worker indefinitely.
Adds the first real CI gate. No workflow ran tests on a pull request — the
deploy workflows build without testing and build-agent.yml covered only
DecisionCore.Tests on manual dispatch — while trust/security-faq.md
already told customers automated tests run in CI. ci.yml now runs all six
suites on every PR and merge, plus a job that fails on generated-guardrail
drift, alongside CodeQL and Dependabot. OfficeBinaryWatcherTests is marked
SerialThreading; its bounded re-probe races the shared ThreadPool and
failed once under full-suite load.
Verified: 3101 backend, 1911 agent V2, 474 decision core, 44 dialog, 572
web, 447 MCP. The timeout fix is verified by runtime, not assertion — a
Start-Sleep 120 command now returns after ~30s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The MCP server could answer telemetry questions ("why did this session fail")
but not product ones ("how do I deploy the agent", "where is my data stored").
Those answers already exist in the autopilotmonitor-docs bundle; search_docs
makes them semantically searchable.
Deliberately a separate index from search_knowledge: rules and prose answer
different questions, and sharing one topK would dilute both. Registered for
every role — published documentation carries no tenant data — and skipped
entirely when no corpus was baked in, so a doc-less build advertises no
broken tool.
Corpus handling:
- Chunked at h2/h3 and <details>/<summary>, each chunk prefixed with a
"Page > Section > Subsection" breadcrumb so a mid-page hit is self-describing.
- Newlines are normalized before any parsing. This is a correctness
precondition, not cosmetics: the bundle ships CRLF and JS `.` does not match
\r, so /^(#{2,3})\s+(.*)$/ matched zero headings — silently, degrading every
page to unlabelled paragraph splits.
- Two size exceptions: tables are never split (a split table leaves `| --- |`
rows with no header), and unbroken changelog lists split at line level (left
whole, their tails fall outside the model window and become unsearchable).
- Short sections are kept — a one-line answer is often the best hit, and the
breadcrumb supplies the context a short body lacks.
Build and delivery:
- Docs are embedded at image build time into an optional third
PrecomputedSection. Optional so a checkout without the docs repo does not
invalidate the rules and event-type vectors; the reverse (server has docs,
file does not) is staleness and is rejected.
- The deploy workflow checks the public docs repo into the build context and
passes its SHA as DOCS_COMMIT, which /health reports alongside the chunk
count — editing the docs does not update a running server, so staleness is
made checkable rather than memorable.
- Embeddings are rounded to 6 decimals on serialize: 55% smaller index (2.63
vs 5.88 MB) for a ~1e-5 norm error against a 1e-3 tolerance. indexPrecomputed
now asserts unit norm, since the hydrate path never checked it.
Infra: the Bicep template was two changes behind the live deployment (EU
cutover and ACR hardening, both applied directly in Azure). Reconciled against
the running resources — managed-identity image pull replaces the admin
credentials and the acr-password secret — and infra/ is no longer gitignored so
it stops drifting. Container raised to 0.5 vCPU / 1.0 GiB for the cold-start
query-embedder load. The template does not model the mcp.autopilotmonitor.com
custom domain, so a full deployment would remove it; the header warns about it.
Verified: 428 tests pass; 45 pages produce 324 chunks; sample queries hit the
right page top-1/2; all 8 derived URLs return 200 live; the precompute output
is accepted by the boot validator and a mutated corpus is rejected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The brave-grass filename and secret name referred to the pre-migration
Static Web App hostname. Workflow now matches the deploy-backend/-mcp
naming scheme and uses AZURE_SWA_DEPLOY_TOKEN (already set to the new
SWA's token). The old secret is removed after the first green run.
Nothing required net8.0 -- the test runner just predated the .NET 10
migration. Agent projects stay net48 (Intune/OOBE constraint).
474/474 tests green on net10.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>