.github/scripts/Get-Endpoints.ps1 defines the storage account (blob and table
derived from its name), the containers, the AdminConfiguration row, the legacy
mirror, the download alias, API, MCP, portal, www, docs and the timestamp
server. Workflows load it once per job; the script publisher and the local
release build call it; the dev-render anchors are built from the alias entry.
deploy-web takes NEXT_PUBLIC_API_BASE_URL and NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL
from the registry instead of repository variables and refuses an empty value.
A new Workflow gates job fails on any host spelled out in a workflow or CI
helper; the enforced list is derived from the registry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every job that runs third-party code (restore, build, tests, npm ci, next build,
wix) now holds only a read-only token and hands its output on as one archive.
Signing, publishing and deploying happen in separate jobs on a fresh runner that
run only this repository's scripts and pinned actions, unpack artifacts outside
the checkout and take no build-job output into a script.
- build-agent: reserve -> build -> release
- build-msi: sign-loader -> build -> release
- deploy-backend: reserve -> build -> deploy (publish output as one tar.gz)
- deploy-web: build -> deploy (SWA token only, no id-token) -> record
- combine-rules: validate (read-only, PRs too) -> publish (push to main only)
- deploy-mcp: same shape; documents docker build as its boundary
Deploy-only jobs run in the production environment. The jobs that sign stay
without an environment for now: a job carries one OIDC subject, and the signing
identity still trusts the ref. oidc-login-test.yml is removed; combine-rules no
longer triggers on the dev branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build-agent.yml tested only DecisionCore (648 of 4,154 agent tests). It now runs
the DecisionCore, Agent.V2.Core and SummaryDialog suites before building the
release binaries, with the reserved build number.
The order matters: Agent.V2.Core.Tests references the agent exe, so its build
compiles the agent. Run after the release build or without BUILD_NUMBER, it
rewrites the exe with another version (a fresh checkout stamped 2.0.1 in a local
simulation). Run first with the number, it stamps the release version and the
Build Agent step stays an up-to-date no-op (same FileVersion, same write time).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Names the helper homes (web lib/, backend Functions/Helpers/) and asks for a
search by what a helper does before writing a new one, which no tool can judge.
Lists the local commands for the new gates, since CI only reports.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tools/quality runs jscpd (5.3.0, pinned with a lockfile like rules/scripts)
over the C# and TypeScript sources, tests and generated files excluded, at 100
tokens. The 95 existing clones are frozen in jscpd-baseline.json. A clone that
is not in the baseline fails, and so does a baseline entry whose clone is gone:
jscpd only reports missing fingerprints, and a stale one would let the same
copy come back unnoticed.
jscpd scans an LF-normalized copy of the tracked and new files under src/,
because with CRLF in a Windows checkout some JSX clones hash differently than
on the Linux runner; local runs and CI now see the same 95 clones.
At 70 tokens a sixth of all clones were identical using blocks, hence 100.
Gate proven both ways with an untracked probe file: a third copy of an existing
helper fails with its location, and a baseline entry without its clone fails.
New CI job "Duplicate code"; Dependabot watches tools/quality with the usual
7-day cooldown. Locally: npm run dup (check), npm run dup:baseline (shrink).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The root .editorconfig raises IDE0051, IDE0052 and IDE0060 (non-public
methods) to warning, and Directory.Build.props sets EnforceCodeStyleInBuild,
so the warning freeze turns them into build errors in every project, the net48
agent included.
Cleanup to start at zero, each finding checked for leftover vs. missing
behaviour (none was a feature that silently never ran):
- 38 injected dependencies nobody read: loggers, configuration, and services
whose last read had moved to a live replacement (the IME reseed writes
through the rule repository, the offboarding poison OpsEvent comes from the
handler, the vulnerability gate sits in the correlate handler). Removed with
their constructor parameters and the argument at every construction site.
- unused private members: SelfUpdater.IsNewerVersion, a GatherRuleExecutor
wrapper, test constants and an unused test overload.
RjPhaseCompletedFirstDeployment now replaces the two literal 110s it names.
- unused parameters removed together with their arguments; test fakes bound as
method groups use discards.
- the commented-out backend-URL connectivity check and its plumbing are gone;
the reason the backend stays out remains as a comment.
- DiagnosticsPackageService names the blob in its upload-failure warnings, as
its doc already said.
- stale docs fixed on the way: OnboardedAt no longer claims a maintenance
backfill (shared manifest and wire types regenerated), the vulnerability
functions' policy comments match the catalog, a misplaced summary moved to
its method.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
knip (6.37.0) runs in both TypeScript projects as its own CI step
(npm run knip) next to ESLint. It builds the module graph, so "nothing
imports this" is checked against every reference instead of a search.
Generated files stay out of scope (lib/generated, src/generated); the
MCP's Dockerfile-run precompute and its manual scripts are entries, and so
is the web's compile-time manifest check.
Cleanup to start at zero:
- web: four orphaned components (AdminPageSections, SettingsMenu, UserMenu
(the navbar renders its own menu) and MarkFailedModal), 17 unused sidebar
icons, four unused config constants (the agent-download host allowlist lost
its only reader with the /go route in July), the push device-status union
that existed twice and was used nowhere, an uncalled push error helper,
unused barrel re-exports, default exports duplicating named ones, four
superseded DTO interfaces, and a JSDoc type import of an undeclared package.
- mcp: two test helpers nothing calls.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The MCP server had no ESLint, and since its tests left the tsconfig (to keep
them out of dist/) nothing type-checked them; scripts/ never was. tsconfig.json
now type-checks sources, tests and scripts (noEmit); tsconfig.build.json emits
the same dist/ as before (byte-identical) for npm run build and the Docker
image. ESLint (the web's versions: eslint 9.39.5, typescript-eslint 8.65.0)
runs the recommended rules plus the typed promise rules, and CI's mcp job runs
it with --max-warnings 0.
Findings fixed: 16 type errors in tests (a fixture missing three wire fields,
unknown catch results, untyped mocks); gracefulShutdown was async without
awaiting anything, so the signal handlers returned a promise; two dead imports;
a Function cast replaced by the exact pipeline() signature; an unused test
variable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web app kept helpers in two root folders (lib/ and utils/) and named
route-local helper folders either utils/ or lib/. Shared helpers now live in
lib/, route-only helpers in a lib/ next to their route (app/dashboard/lib,
app/sessions/lib). Generated wire types, manifests, guardrails, error codes and
the lastmod file move to lib/generated/, like the MCP server's src/generated/.
The session-search parity cases sit next to sessionSearchQuery.ts.
Pure move, no behaviour change. Imports, the generators (combine.js,
generate-shared-manifest-types.js, sync-error-codes.js, generate-lastmod.js),
the CI drift paths, the two backend tests and the MCP freshness test that read
these files, and the guard tests' registry path and scan list follow the new
locations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A health row goes once its channel is no longer in the stored channel list of its scope and it
saw no send for 30 days. The channel lists are read strictly, so a failed read skips the scope
instead of reading as "no channels"; the delete is ETag-conditional, so a row a send just
rewrote survives. Runs on the 2-hourly maintenance timer next to the push retention.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every non-Push send, real or test, is recorded in a NotificationChannelHealth row per channel
(own table, server-side status rule, destination fingerprint resets the history). The settings
show a status dot per channel: Operating normally, Failure rate x%, Error with the cause, or No
deliveries yet. Two failed deliveries in a row raise one bell for the tenant admins (or the GA
bell for ops channels) per failing episode; a failing channel keeps receiving deliveries.
Each transport now has one send path that reports its outcome (WebhookTestResult is
NotificationSendResult, the dispatcher's test entry is SendTestAsync); failures are logged with
tenant and channel id. New routes GET config/{tenantId}/notification-channel-health
(TenantAdminOrGA) and GET global/config/ops-channel-health (GlobalAdminOnly).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- TableStorageService.GetEnrolledTenantIdsCachedAsync: configured tenants with a PlatformStats counter row (RowKey 'current', TotalEnrollments > 0), 60 s single-flight; fail-open to the configured list with a Warning when the counter scan fails
- Session list pages, cross-tenant stats drain, geo window and app-summary snapshot fan out over that subset (353 -> 127 partition queries per page live); fresh-install fallbacks key on the configured list, a configured set nobody enrolled in is an empty page; audit logs, QuerySessionsByDateRangeAsync and maintenance keep the full list
- Web: cross-tenant follow-up pages fetch max(pageSize, 100) (resolveSessionsFetchPageSize); first page and own-tenant list unchanged
- Tests: CrossTenantFanOutTests (enrolled subset, counter 0 / no row, fail-open, unconfigured counter row, empty enrolled set never the legacy scan, bound after the filter, geo + apps), DelegatedBoundedAggregateTests harness, sessionsPageSize.test.ts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A session without a Sessions row can never be reached by the cascade, so every row written for it was an orphan by construction. Three layers now keep the platform clean:
- Prevent: IngestTelemetry answers a batch for an unknown session with 410 (same status and body as the cascade lock; request-row marker IngestRefusal = session_unknown | cascade_locked). The derived writers - vulnerability correlation, analyze, manual rescan - stop on SessionDeletionGuard.GetLivenessAsync != Live. The agent treats every 410 as terminal: one TelemetryRejectedSessionUnknown report (exempt from the reporter cooldown), then terminate_session with forced self-destruct (origin session_gone, no final drain, no diagnostics upload); spool items of a foreign session id are dropped at startup and never terminate the current session.
- Detect: OrphanSessionSweepFunction (every 4h at :45, own lease) diffs the Sessions keys against the EventSessionIndex handles - two range drains instead of ~16.8k Sessions point reads per 2h maintenance run - and re-checks every candidate (handle unchanged, row still absent, tombstone) moments before deleting. The in-maintenance sweep raced the retention cascade on their shared 00:00/12:00 tick and reported false orphans; it is gone.
- Clean: every table of the cascade manifest minus the tombstone pair (ratchet in TableLifecycleBucketTests), the inventory counters with the stamp on the handle (claim, then decrement), the handle last with If-Match. A weekly read-only reconciliation of EventTypeIndex against the Sessions keys (Sunday mornings UTC, and on every manual maintenance run) reports residue.
MaintenanceCompleted now carries stepsMs. MCP search_sessions_by_event auto-exhausts empty-but-continuable pages. Manifests regenerated for the new ops event types and AdminConfiguration.OrphanDeepCheckLastRunUtc.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Customer domains, tenant-ID fragments, two full customer tenant GUIDs and
four real session GUIDs had found their way into code comments, test
constants and a fixture comment. They are replaced by neutral descriptions
("field case", "kiosk tenant") and synthetic values; no behaviour changes.
A check of every onboarded tenant's domain and ID prefix against the tracked
files finds no customer identifier left.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The retention fanout reads the tenant configuration strictly. A storage
failure used to yield the synthesized default (90 days), which deletes
sessions and prunes rows a Pro-365 or never-delete tenant keeps. Now the
tenant is skipped and counted: tenantsFailed on
SessionDeletionMaintenanceCompleted, which turns Warning. A row that
vanished since the tenant listing is skipped too, instead of being
recreated with defaults.
- GetDeviceHistoryAsync throws on a read failure instead of returning "no
history". Both writers merge into the chain they read and replace the row,
so the old behaviour dropped every older ref. The sweep skips such devices
and leaves the FTR aggregates untouched for that tick.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- TableKeySanitizer replaces ten private and inline copies of the key rule
(/, \, #, ? become _) and also removes control characters, which Table
Storage rejects in keys and the HTTP layer rejects in a point read's URL.
Keys without control characters are unchanged; a source ratchet forbids new
copies. The dead UpsertSoftwareInventoryAsync is removed.
- VulnerabilityInventoryLoader hands out inventory text without control
characters, so CPE lookup, report text, side-row identity and counter keys
all see the same text.
- A correlation run whose SoftwareInventory counter writes or contributions
side-row fail writes one SoftwareInventoryCounterWriteFailed ops event
(Maintenance, Warning; counts, error type and status, up to five sample
keys) instead of a per-key entry in the tenant audit trail. The counter work
is awaited inside the invocation instead of a detached Task.Run.
- The new type is listed in the alert-rule catalog; shared manifest and MCP
vocabulary regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some installers pad REG_SZ values with NULs. .NET Framework drops only one
terminator, so the rest rode into the normalized software identity and,
server-side, into a table key whose point read the HTTP layer rejects
("400 Invalid URL"). DisplayName, DisplayVersion, Publisher, InstallDate,
InstallLocation, UninstallString and ParentKeyName are now read the way
Windows shows them; a DisplayName of NULs only counts as empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fleet Health, the Software hub (Installs, Vulnerabilities), app details, Geographic
Performance and its drill-down, Usage Metrics and Analyze Rules read ?days= through one hook
(useWindowDays). The URL is the only state: a preset click writes it with replaceState, the
page default stays out of it, and a link to another app on the same route refetches. Pro
reaches up to 365 days through the URL alone; the presets stay 7/30/90 and a value outside
them highlights none.
Cap: platform roles 365 in every scope, everyone else the viewed tenant's edition cap from its
feature flags (Community 90, Pro 365), read only when the URL asks for more than 90 days. The
cap never rewrites the URL. Fleet Health draws weekly bars beyond 90 days, the Analyze Rules
sparkline keeps its width and bundles days beyond 30, long windows keep their own progress
estimate. The duration-regression bell's days=35 link no longer snaps to 30.
The string presets and their six fall-through mappers are gone; Usage Metrics passes days to
the API, the Software hub and Geographic Performance share their defaults with the pages that
link back to them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
First-time-right, time-attribution and rule-statistics rows lived on fixed lifetimes (180,
180 and 90 days), a storage bound reused from the never-pruned tables, never weighed against
tenant retention: a Community tenant could read first-time-right rows for days 91-180 through
the API and MCP, a Pro tenant keeping 365 days lost them after 180 and its rule statistics
after 90.
The retention fanout now prunes per tenant, before its session scan: these rows at the
tenant's effective retention (downgrade grace included), the audit trail at
max(180, retention) up to 365. The maintenance cleanups become outer bounds and platform
lifetimes in DataLifetimes: no tenant row beyond 365 days, global first-time-right rows 365,
time attribution 180, global and legacy rule statistics 90, the platform audit partition and
legacy audit rows 180. Device histories already follow their sessions and stay as they are.
The device-journeys window goes to 365 days (route cap and MCP get_device_history). The
SessionDeletionMaintenanceCompleted ops event carries tenantRowsPruned.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tenant that is Pro only through its managing organization ("Pro (MSP)") could not save a
data retention above 90 days: the patch and revert paths validated against the raw table row,
which never carries the read-time projection, so the edition resolved to Community while the
feature flags and the settings slider offered 365.
The validator now gets a projected copy of the stored row. The row itself stays
projection-free, because the exactly-these-fields verify diffs against it; a projected row
would read as drift and roll every save back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chrome and Edge run the timers of a tab hidden for five minutes once a minute; the client then misses
its keep-alive, the service closes the connection and the automatic reconnect restores it, a loop every
minute in every background tab. lib/signalrPark.ts, a pure state machine tested with an injected clock,
parks the shared connection after four minutes hidden and resumes it on the first visible signal. The
resume ends a gap, so every view re-reads through useSignalRResync. A failed resume retries with backoff
up to 30 s until the next park; no park starts during an accessRevoked restart. Telemetry:
signalr_parked { hiddenMs } and signalr_resumed { parkedMs, transport }; park and resume emit neither
signalr_disconnected nor signalr_connected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pushes only announce changes, so after a gap a view has to check the backend. Only some did: the
bells re-fetched from connection.onreconnected (handlers piled up, nothing after a restart), the
Progress Portal never re-read (a page left open across the end of an enrollment kept its last
state), and the session page re-read only its events. The provider now counts the ends of gaps
(a reconnect after its rejoin, a start of a connection that was up before) in resyncEpoch, and
useSignalRResync(onResync) fires on each once the group joins of that moment have settled, so a
push sent between a re-read and its rejoin cannot get lost; a joinGroup that finds a join of the
same group in flight now awaits it. Bells, Progress Portal (summary and events), session page
(session, analysis, vulnerability report; events keep the join catch-up), diagnosis page,
dashboard list, dashboard stats and fleet health use it; the wasConnectedRef checks are gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The host decodes %2F to a path separator before routing, so apps/{appName}/analytics|sessions and
their global twins answered 404 for every app whose name contains '/', before any function ran;
a name with a literal escape such as '%20' was decoded a second time. The routes are now
apps/analytics, apps/sessions, global/apps/analytics and global/apps/sessions with ?appName=, and
the unblock route is DELETE devices/block?serialNumber=&tenantId= (serials may contain '/' too).
Function names, policies and tenant scoping stay; req.Query decodes exactly once. Tests run names
with '/', '%', '+', a space and a literal escape through the catalog and the handlers, and the web
builders put the name into the query. Manifest and wire types regenerated (doc text only).
Rollout: backend, then web right after; an app-detail tab on the old bundle 404s until reloaded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>