- TenantConfiguration.McpUsagePlanOverride: a SectionUsagePlans plan name pinned to a whole
tenant (members' default user plan + organization windows); precedence per-user override >
tenant override > edition default; never changes the edition
- PATCH config/{tenantId}/plan accepts mcpUsagePlan (validated against the definitions,
400 on unknown names; null/blank clears), audited, echoed as mcpUsagePlan/mcpUsagePlanOverride
- Tenant Management: "MCP usage plan (override)" select fed by global/config/plan-tiers
- Quota 429: every Community block now says Community is sized for occasional use and Pro
lifts the window; managed-tenant block names whose plan governs it
- Field in BaseDeniedFields/RevertProtectedFields/projection, Store/Map, manifests regenerated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase A — the budget follows the data
- A delegated (MSP) read is charged to the MANAGED tenant's organization MCP budget and checked against
its plan; the caller's own budget still follows the home tenant. 429 names the managed tenant (domain)
and the Pro upgrade path; new McpQuotaExceededResponse.targetTenantId.
- Bounded fleet aggregate (global/sessions, global/stats/sessions): exhausted managed tenants are excluded
softly — RequestContext.AllowedTenantIds is narrowed (never widened, never null; pinned) and echoed as
quotaExcludedTenants. Global Admins stay exempt and are tracked on their home tenant only.
- Tenant Group flag ChargeHomeTenantQuota (PATCH global/tenant-groups/{id}, audited per tenant): reads
through the group are charged to the assignee's home tenant (operator-managed service groups).
- McpTenantUsage rows carry UserPrincipalName / HomeTenantId / LastRequestAt; new
GET metrics/mcp-usage/organization (budget by account incl. delegated readers) rendered on MCP Usage.
- MCP: get_fleet_overview (delegated + platform) over the two subset-tier routes; error handler names
the managed tenant. Other delegated tools stay tenant-mandatory.
Phase B — delegated tenant slots
- EditionEntitlements.MaxDelegatedTenants (Community 0 / Pro 2), TenantConfiguration.MaxDelegatedTenantsOverride
(GA-only via PATCH config/{tenantId}/plan { maxDelegatedTenants }), DelegatedSlotService counting distinct
managed tenants per home tenant through identity bindings (+ pending invitations + 24 h holds), enforced
fresh on grant / group assign / add-tenant with 409 DelegatedSlotLimitReached; GA UI offers
"raise to N & retry"; GET global/delegated-slots/{tenantId}; feature-flags entitlements.maxDelegatedTenants.
Phase C — self-service delegation
- One implicit Tenant Group msp-{tenantId} per managing tenant (OwnerTenantId on the meta row);
DelegationInvitations table (invitations + slot release holds); DelegationInviteTicket (HMAC, 7 days,
locates the row only — the accepting tenant is always the JWT tenant; one-shot via ETag).
- DelegationSelfService: ordered accept chain (distinct codes), remove/revoke with 24-hour slot hold, dual
audit and live-stream disconnect, assignees bound to tenant membership, "who manages my tenant" view;
GA release-hold escape hatch. No delegations/* route carries {tenantId} (pinned).
- Web: Settings → Tenant → Delegated Access, /delegations/accept?token=, AuthGate keeps the deep link
across the activation detour, Tenant Management holds + release, owned groups read-only in GA UI.
- Offboarding wipes both sides (documented two-bucket membership); 90-day sweep of terminal rows.
- Trust pages (terms, privacy) and generated wire types updated; docs submodule bumped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CodeQL flagged the re-save in AuthGate (js/clear-text-storage-of-sensitive-data,
alert #82): the value flowed back into sessionStorage from the consumed
return URL. The link only has to survive one more sign-in round trip, so the
re-home path now reads it without clearing and never writes it again.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Session details, diagnosis, annotations, fleet health, geographic performance,
SLA, software, usage metrics, audit log, system health, IME log patterns and the
rules pages link to their guide from the end of the header control group.
Navbar help links use the DOCS_PATHS catalog (service announcements no longer
goes through a redirect).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Backend: the app-homing consent probe now requires the primary app's Graph
roles to be a superset of the legacy app's, so a mere sign-in consent
(User.Read, no application role) can never auto-flip a tenant onto an app
without the device-validation permission.
- Web: dashboard banner for tenants still on the previous app (per-tab
dismiss, docs link); docs links on the settings funnel banner, the flipped
banner and the failed-sign-in screen; immediate re-homing after a redirect
sign-in on the other app (one hop per tab, never after a cross-app
fallback); the www -> portal handover passes the learned app as ?authapp=.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The dashboard selection could grow across pages without limit and a bulk delete was one click on the red button. The effective selection is now cut to BULK_MAX_TARGETS (100, the largest page size) and every pick path (row, Shift range, page checkbox) fills only up to the remaining room. The delete dialog lists every target and, above BULK_TYPED_CONFIRM_THRESHOLD (10), asks for the word DELETE before the button arms. Block stays one click because it expires after 24 hours.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CompanyName joins ContactEmail as the tenant contact profile: optional on Community,
both required by the self-service trial start (one 409 ContactProfileRequired verdict
naming every missing part). GA plan assignment stays soft (admin warning). Feature
flags expose companyNameSet as a boolean only; TenantTrialStarted carries companyName.
Web: Contact section field, Plan gate and dashboard banner name what is missing,
admin panel/report/search know the company. Privacy page lists the new datum.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The organization-quota card called toLocaleString on undefined when the
backend predates the tenant quota (deploy window, local dev against prod),
tripping the error boundary. Tenant fields are optional on the client now;
the card renders only when they are present.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- SectionCardHeader replaces 33 hand-copied card headers across settings, rules and admin pages
- DocsLink + lib/docsPaths: every tenant-facing section links its documentation page (new tab)
- docsPaths guard test encodes the GitBook URL rule (group slug, not folder name)
- delete dead MaintenanceSection duplicate
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every X-Client-Source: mcp request is now charged against two budgets: the
caller's own daily/monthly windows (per-user override or tenant edition plan)
and the organization-wide daily/monthly windows of the token's tenant, which
always follow the tenant's edition plan so a per-user override never widens
them. Ten accounts no longer mean ten times the budget.
Tenant counters live in the new McpTenantUsage table (PK = tenantId,
RK = {yyyyMMdd}_{oid}: one partition range per check, no hot row, never a
property scan over UserUsageLog); exact-PK offboarding bucket and 90-day
retention included. PlanTierDefinition gains nullable tenant limits (null =
catalog fallback: Community 300/9000, Pro 3000/60000; 0 = unlimited). The 429
body carries level=user|tenant and a message naming whose budget it is,
X-MCP-Quota-Tenant-* headers ride along, and mcp-usage/me exposes both
budgets (wire types regenerated).
MCP server: the quota 429 body was rendered as Error: undefined because it
has no error key; it now gets a dedicated block with the reset time and a
level-specific suggestion. Web: Usage Plans edits the tenant limits; the
self-service MCP Usage page renders both budgets (the quota node was never
displayed before).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
McpAccessPolicy.AllMembers returned Allowed for any valid JWT, so a Progress
Portal end-user without a role could open an MCP session. The AllMembers path
now requires an effective role (Admin / Operator / Viewer) in the token's
tenant, resolved by the new TenantMemberRoleResolver that the policy
middleware shares (table first, Entra app-role claim when the tenant opted
in). Explicit grants (platform role, delegated scope, enabled McpUsers row)
and the Disabled-row kill-switch keep working in a policy-independent order;
auth/mcp and auth/me hand the token's app roles through.
The MCP Users settings section shows the user list under AllMembers too, as
the per-user override list (individual usage plan, disable = block).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The fixture waited 5 s per posted signal and the scenarios 10 s for a
terminal stage. Those are deadlock guards, not performance assertions:
every applied step performs 5-6 WriteThrough + Flush(true) writes and 2-3
atomic renames synchronously on the ingress worker, and on a cold GitHub
runner volume the first scenarios after the parallel phase still blew the
5 s budget even though the collection is already serialised (run
33581374985: the first four scenarios in run order failed, the other nine
passed; the rerun of the same commit was green). One constant,
EnrollmentOrchestratorFixture.SettleTimeoutMs = 30 s, now covers the
per-signal, terminal-stage and uploader waits.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`dotnet test --verbosity quiet` also sets the console logger to quiet, which
drops the "Error Message" block of a failed test — run 33581374985 showed
only four "[FAIL]" lines for the V2 Core scenario flake and nothing about
which fixture budget blew. An explicit `--logger "console;verbosity=minimal"`
keeps the build quiet but prints the failed tests with message and stack.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The server already strips SignalR Quota, Poison Queues and every endpoint URL
for non-Global-Admins, but the page mirrored the Global-Admin VIEW only for the
build blocks, so demo mode still showed the full operator report. The new
lib/healthCheckView.ts applies the same rule presentation-side (cards + URL
detail rows), and both card render paths share one HealthDetails component
that puts URL values on their own line under the label instead of wrapping
them right-aligned next to it.
The healthy agent-binaries message no longer advertises the legacy blob; that
account stays in the Global-Admin-only details.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The per-client opt-in stays available on the server; this repo's .mcp.json no longer sets it,
so operator sessions run compact too and pay no indentation tokens. Set the header in your own
client config when you need to read raw results.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Compact JSON stays the platform default: measured with an OpenAI-family tokenizer on realistic
payloads, 2-space indentation costs 14-46 % more tokens (2-2.7 tokens per line for newline,
indent and colon spacing) — gzip removes it from the wire, not from a model's context. A human
reading results in an interactive client opts in per client with `X-MCP-Pretty: 1`; the access
guard stores the preference on the caller context and stringifyResult honours it. This repo's
.mcp.json sets the header so operator sessions in Claude Code get readable output. The
MCP_PRETTY_JSON environment switch remains the global escape hatch.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- get_session_diagnostics no longer embeds the ~5k-character ZIP layout per ticket; it points at
the static diag_zip_layout resource (read once per conversation). Prompt text updated.
- get_resource accepts `section` to read one top-level part of the rule-authoring trio; the
section list in the description is derived from the live catalogs. RESOURCE_NAMES now lives in
resource-catalog.ts as the single source of the name enum and the ResourceName type (the inline
z.enum baseline entry is retired with it).
- search_knowledge no longer repeats type/title inside metadata.
- search_events walks up to three candidate event types concurrently (TYPE_WALK_CONCURRENCY);
pages within a type stay sequential, results stay in candidate order, budget/truncation
semantics unchanged.
Deliberately not done (operator decision): a longer undici keep-alive (runtime dependency for a
single-digit-ms in-region gain; the backend sends no Keep-Alive hint) and precomputed tool JSON
schemas (7-12 ms per request on a dev CPU, would need SDK-registration patching).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
30-day tool telemetry: callers pass fields explicitly in more than 90 % of get_session_events /
query_raw_events calls, and reads filtered by eventType keep the payload about half the time
(the ime-decompile skill reads data.msiDownloadUrl after an eventType filter without fields).
So the lean default (no data / no DataJson) now applies only to unfiltered timeline reads and
announces itself in-band (omittedFields + omittedNote); a read filtered by
eventType/severity/source returns full events as before. Never worse than the former default.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Backend
- SingleFlightCache<T>: per instance, TTL, single-flight, failures never cached, expiry sweep, optional single slot
- Rule catalog caches in TableStorageService.Rules.cs: gather rules / rule states / IME patterns per partition (60 s), every Store/Delete invalidates, hand-outs are clones (GatherRule/ImeLogPattern/RuleState.Clone()); GatherRuleService and ImeLogPatternService seed behind a gate and read global/tenant concurrently; GetAgentConfig reads rules and patterns concurrently
- Tenant-id snapshot (60 s) shared by every cross-tenant fan-out; the paged session list no longer rescans TenantConfiguration per page
- BoundedFanOut + DrainSessionsIndexWindowAsync: one PartitionKey-anchored, RowKey-bounded, projected query per tenant; GetAllSessionStatsAsync drains with SessionStatsProjection under a 30 s single-flight cache; GetGeoWindowSessionsAsync reads the index key range; GetSessionsByDateRangeAsync fans out per tenant
- App summaries: cross-tenant windowed projected reads fan out per tenant over a union projection and share a single-slot 60 s snapshot (Installs tab pair, app detail, usage refs); days clamp on global/metrics/app
- CancellationToken on the uncached geo/date-range reads (IMaintenanceRepository)
- EventFieldProjection accepts data.<key> slices
- BackendStartupMs measured from Main (StartupClock) instead of Process.StartTime, which carried the Flex placeholder lifetime
Web
- Dashboard stats: 30 s SignalR debounce in the GA aggregate scope (every tenant's events drove a cross-tenant drain every 3 s)
MCP
- Compact JSON tool results and resources (MCP_PRETTY_JSON=true opts back in)
- DEFAULT_FIRST_PAGE_SIZE 50 for every paginated tool; the auto-exhaust budget is row-based (2000 rows) so scan coverage does not shrink
- Lean first-page projections: get_session_events without data, query_raw_events without DataJson; announced in-band (omittedFields/omittedNote) and restorable via fields; a continuation keeps the nextLink projection
- get_session_summary fetches the timeline with the triage fields plus data.<key> slices for its guards
Docs: docs/backend/read-caches-and-cross-tenant-fanout.md (submodule 11fc393).
Deploy order: backend, web, then MCP (the summary's data.<key> projection needs the new backend).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Opt-in Select button (Admin Mode) adds a checkbox column, row-click
toggles selection, Shift+click ranges, header checkbox selects the page,
Esc/Done leaves. Bulk actions loop over the per-session endpoints with
three requests in flight and one summary toast; no bulk endpoint.
Confirm modals list the targets. Also translates the block tooltip and
the block error alerts to English.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 2026-08-23 instrumentation cutover re-fired as per-path share regressions
(sweep:r6 lift 8x, sweep:r5_incomplete 5x) because the single stamped baseline
day already cleared the 5-hit floor while the legacy:* history was not counted.
Rule-shaped legacy rows now fold into the per-path BASELINE of sweep:{rule}
(baseline only - derived rows never become window evidence). Failed rows whose
reason is the agent's esp_failure message derive as agent:esp_failure_fallback,
matching the stamped ingest path, which removes the fake agent:failed drop.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The V2 Core integration scenarios drive the real orchestrator against
wall-clock budgets (5 s per signal, 10 s for Completed / uploader). In
isolation every scenario finishes in under a second; inside the fully
parallel suite on a 4-core runner the same scenarios took 4-7 s locally
and blew the budgets on two slow GitHub runners (CI runs 33564658714 and
33565773888). Serialising them after the parallel phase, like
EnrollmentOrchestratorTests and RecoveryPathTests already are, brings
them back under 700 ms without changing the budgets.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A plain wrapper around dotnet restore/build that nothing referenced. Its
"next steps" advertised a local self-hosting workflow (Azurite, func start)
the project does not support, and its name collided with the real agent
release script scripts/Deployment/build.ps1 that the docs and workflows
mean by "build.ps1". Building is `dotnet build AutopilotMonitor.sln`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Test code used the real test-tenant GUID, a real UPN, a real VM serial (and
its Intune-normalized form) and a real hostname as literals. None of the
assertions depended on the specific values; they are contoso/1111…/1234… now.
The one that stays is CertTenantBindingTests: its tenant GUID is stamped
inside device-cert-sample.pem and can only change together with the fixture.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two Bicep templates — and the deployment scripts still to come — do not
belong in a public repository. They now live in okieselbach/Autopilot-Monitor-infra
(full history via git-filter-repo) and are mounted back at infra/, so the code
comments pointing at infra/mcp-server.bicep stay valid. No workflow reads
infra/, so CI is unaffected. The *.parameters.json ignore rule moved into the
submodule.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The screenshots and the logo lived in docs/images, which is now a private
submodule and cannot render on the public README. They are README assets
only (no doc page references them), so they move to .github/assets — the
usual home for repository-level images — and leave the docs bundle.
The architecture link pointed into the private docs; the technical
documentation is not part of the public surface any more.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This repository is public; the technical docs carried security-mechanism
gaps, incident details and operator process. They now live in the private
repository okieselbach/Autopilot-Monitor-docs-internal (full history,
extracted with git-filter-repo) and are mounted back at docs/, so every
code, skill and memory path reference stays valid.
log.md is gone: it duplicated the commit messages. index.md remains the map.
CLAUDE.md: concept docs only for invariants and decisions a later change
could silently break; commit inside the submodule first, then bump here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four Graph-backed validators ran on the unnamed HttpClient (100 s default timeout), two attempts each, sequentially, with an uncancellable token retry chain underneath: GetAgentConfig tails of 30-102 s for an agent that gives up after 30 s. DeviceValidationBudget gives the chain one 20 s token and each attempt a linked 8 s token that also bounds token acquisition; exhaustion is a transient result (503 + Retry-After) and the second attempt is skipped once the chain token is spent.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The lookup scanned only the newest 100 SessionsIndex rows, so any enrollment older than that horizon answered 'Device Not Found' and a member substring search could land on a newer device. Exact serial and device name now run as server-side eq filters over the tenant partition (stored form or upper-case), newest session wins, then a point-read; the substring page remains the members/GA-only fallback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CWE-807: the kill switch was keyed only by the caller-declared X-Device-SerialNumber, so omitting or forging the header dodged block and kill. A block now mirrors onto the device's certificate identities (Sessions.OwnerDeviceId) as id:{guid} alias rows in BlockedDevices, resolved at block time; KillSwitchEvaluator checks serial, then identity, then version and stamps the DeviceIdentityBinding request dimension. Alias rows are hidden from listings and exempt from the legacy-key migration; unblock removes them. Session-scoped watchdog blocks were unreachable (no session id ever reached the evaluator): ingest now carries the verdict past the body parse and re-evaluates with the session id, and holds the registered serial against the block list when the header differs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Records the import-session skill: the 23-step cascade inverse, the single
replacement table over keys and strings, what never travels (Owner*,
deletion/diagnostics columns), the SoftwareInventory counter semantics,
scrubbing rules, provenance lane, and the safety model (insert-only under
the new id, key invariant, zero-leak assertion, automatic rollback).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The generator still listed the removed docs sections, roadmap and changelog
(all moved to docs.autopilotmonitor.com / retired), and lacked /get-started,
which the sitemap emits -- so that entry got "now" as lastmod on every build.
- PAGE_MAP now matches exactly the URLs app/sitemap.ts emits
- generator fails on a missing source file instead of using the deleted
file's last commit date
- sitemap throws at build time for a URL without a lastmod entry
- fix stale comment: the /docs redirects live in staticwebapp.config.json
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both annotation list routes (sessions/annotations/list, global/session-
annotations) accept ?q= -- a case-insensitive substring over the note and
the verdict, deliberately not the lane, author or rule ids, which have their
own filters. Table Storage has no contains, so it is applied in the same
client-of-Azure back-fill loop as ruleId (filtered-out rows never consume
page budget) and bound into the continuation fingerprint, so a cursor issued
for one search cannot be replayed against another; nextLink echoes it.
Portal: a debounced search box on the Annotations page (the setState lives
in the timer callback, not the effect body). MCP: list_session_annotations
gains `query` -> q. Response DTO unchanged, no wire break.
Why: demo sessions imported into the operator's own tenant fall out of every
dashboard/geo/SLA time window. A note ("wifi switch") plus this search is
how they are found again -- and it is generally useful for any judged session.
Deploy order: backend first, then web and MCP (the parameter is silently
ignored by an older backend).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
query_raw_events timed out twice on the same continuation and lost all work each time. Per page the
cross-session path resolved every session's tenant through a SessionsIndex full-table scan (the
EventTypeIndex row already carries TenantId), ran up to 1000 per-session queries serially, applied the
date window last and against the Azure system Timestamp, and had no deadline; the retry was identical
because the nextLink freezes pageSize and the explicit value was dropped.
Backend
- RawEventsScan: index walked in chunks of 100, 20 parallel session fetches, 15 s budget checked
between chunks; cursor always after the last fully processed chunk; QueryRawEventsResponse.Partial
marks a budget-ended page (nothing missing up to nextLink).
- Date window pushed down: Events RowKey range inside the session partition
(BusinessTimestamp.EventDateFrom/ToClause) + sound index write-time pre-filter
(startedAfter - MaxFutureToleranceHours - 1h). The session-StartedAt RowKey was rejected as a
prefilter (WhiteGlove Part 2 emits weeks later).
- Raw date filters and ordering now use the business time (RawEventTime: OccurredUtc -> RowKey ->
system Timestamp); unparsable startedAfter/startedBefore is a 400; bare values are UTC.
- Same scan sat behind search_sessions_by_event, search_sessions_by_cve and the deviceProperties path
of search_sessions: all read Sessions by point read with the tenant their index row carries
(BatchGetSessionsByKeyAsync, IndexRowKeys.ResolveTenantId); the scan-based batch helper is gone.
MCP
- followNextLink(..., overrides): an explicit pageSize (and fields on the raw tools) applies to a
continuation in every nextLink-paginated tool; pageSize schemas lose their .default(),
pageSizeForCall sends the first-page default only on a first-page call.
- scanWithTimeoutFallback on the auto-exhaust tools: one retry with a halved pageSize on the same
cursor, marked retriedWithPageSize; timeout error text distinguishes continuation calls.
- Generated wire types regenerated (partial?: boolean).
Docs: docs/backend/raw-events-scan-budget.md (design, invariants, audit of the remaining unbounded
cross-tenant aggregations with live durations), business-timestamps.md consequences.
Deploy backend first (additive field), then MCP.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The "Internal (Autopilot Monitor)" lane is labelled "never visible to the
tenant" and sits on the session page shown in every live demo, but it was
gated on the platform identity alone and stayed on screen when the
Global-Admin view was switched off.
visibleLanes() now takes the view flag as an explicit second argument and
combines it with the identity, so the rule also covers the read-only Global
Reader. The /annotations overview additionally filters the rendered ROWS to
the visible lanes: that list is not lane-scoped by the request, so an
internal row would otherwise surface there even with the lane dropped from
the filter dropdown.
Not a permission change -- the backend filters the lane for tenant callers
regardless (GetSessionAnnotationsFunction.FilterLanesForCaller) and owns the
write matrix.
Also fixes the retention-override banner: the bare warning glyph inherited
the base 16px size against 12px text and sat below the first line, with an
mt-0.5 hand-nudge that could not hold it. Replaced by a w-4 h-4 SVG whose
box matches the text-xs line box exactly, so items-start aligns it with no
margin -- and it now matches the icon style of every other alert box.
The IME log pattern controls needed no change: canEdit already reads
isGlobalAdmin && globalAdminMode, so demo mode reaches them through the
effective flag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(web): demo presentation mode for live product demos
The Global-Admin view toggle only ever reached the nav and the scope hooks.
Every GA extra inside a tenant page was gated on the identity and stayed on
screen when the toggle was switched off -- including the /health-check build
cards, whose commit hashes hyperlink into the private repo, and the Decision
Inspector button on the session detail page.
New useGlobalAdminUi() (real GA *and* global view on) replaces the bare
identity check at the five visible GA surfaces, so switching the toggle off
now yields a genuinely tenant-admin-looking view.
On top of that a hidden demo mode, armed by a self-consuming ?demo=1
(persisted to localStorage, then stripped from the address bar), forces the
global view off for every consumer without touching the stored value, hides
the toggle itself and the platform role badges, and silently replaces
/admin/* with /dashboard rather than rendering the Access Denied card --
whose text would itself reveal that a platform area exists. ?demo=0 clears
it and restores the previous setting. An unrecognised value leaves the
stored flag alone, so a typo cannot drop the operator out mid-presentation.
Admin Mode stays untouched: a tenant admin has it too, so it belongs in an
honest demo. user.isGlobalAdmin is never faked globally -- that would drag
canEditConfig along and leave a GA without an own tenant-admin role unable
to demonstrate anything.
Also hides the settings gear when neither toggle applies, which previously
opened an empty panel for Operators and Viewers as well.
Presentation only. Every rule mirrored here stays enforced server-side
(TenantConfigValidation, GlobalAdminOnly -> 403); no backend file is
touched and demo mode is not a security boundary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(web): refresh generated page-lastmod
Regenerated by the build (scripts/generate-lastmod.js). The committed file
had drifted: about/privacy/terms carried stale dates and /buy, /help and
/plans were missing entirely, so the sitemap under-reported them.
Pre-existing drift, unrelated to the demo-presentation-mode change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 2026-09-01 WG-seal soak on post-SignalIngress-fix builds (memory:
project_wg_seal_postfix_soak_residual) found sessions whose DecisionEngine
never sealed Part 1 while the agent kept running for hours - and telemetry
could not distinguish a frozen ingress worker from a deliberate Weak
sealing-classifier verdict, because the metrics snapshots carried no
pipeline counters at all.
Every agent_metrics_snapshot now carries the block. The fields are MONOTONIC
totals plus a monotonic queue peak - deliberately no momentary queue length:
a snapshot samples the integral, so any two snapshots prove whether the
worker ran in between (a frozen worker keeps signal_pending_count growing
while processed/applied stand still; a momentary gauge would alias between
ticks). Same field pattern the snapshot already uses for the spool
(spool_total_enqueued_count / spool_peak_pending_item_count).
- signal_last_ordinal / signal_processed_total (new ProcessedSignalCount) /
signal_pending_count / signal_reentrant_total: SignalIngress worker
progress; signal_queue_peak is a new lock-free Interlocked-max recorded in
Post (fast + slow path; worker-local follow-up items deliberately excluded)
- decision_stage + decision_last_applied_ordinal: from the immutable
DecisionState - answers the WG question directly ("minutes after the
tracker's whiteglove_complete and still not WhiteGloveSealed")
- wg_sealing_level / wg_sealing_score: only after the sealing classifier
produced at least one verdict (key ABSENCE, not Unknown/0 noise, for every
non-WG session) - separates "classifier said Weak" from "never ran"
Wiring is a read-only probe chain following the imeTrackerHealthProbe
pattern: EnrollmentOrchestrator passes () => _processor?.CurrentState into
IComponentFactory.CreateCollectorHosts (new optional decisionStateProbe;
test fakes updated), PeriodicCollectorLifecycleHost combines it with its
existing concrete-SignalIngress reference in BuildSignalPipelineProbe, and
AgentSelfMetricsCollector writes the fields fail-soft (a throwing probe
degrades to a snapshot without the block - pinned by test). The snapshot
event travels the collector/event path, which is independent of the signal
pipeline it measures - field-proven by the freeze cases, whose perf/metrics
events kept flowing while the engine stood still.
No backend/web/MCP change needed: no new event type, DataJson is free-form.
Tests: 5 new collector facts (full block, absence semantics x2, fail-soft,
no-probe compatibility) + 2 new SignalIngress facts (processed total stable
across drain; queue peak survives the queue draining - the property a
momentary gauge would not have). Agent suite 2812 green, solution builds
with 0 warnings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 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>
Three CmTraceTimeSkewRegression fires on 2026-09-01 (one tenant, sessions
e797117b / c06d639d / d7c8032b, agent 2.0.1445, each exactly -60.0 min,
residual 0.0, 85-88 % grid-conformant) all came from the same stale leg: those
sessions are pre-provisioning, their Part 1 ran on 2026-08-20 under agent
2.0.1409 (before per-line anchoring) and left 23-26 IME samples at -60.00,
while the Part 2 leg that completed that morning is clean at -0.16. The
terminal reconcile scans the whole event partition, so the old leg supplied the
majority of the IME samples and fired against a build the device had
self-updated to minutes earlier. Real skew, 12 days old, already fixed - and
>=821 pending pre-provisioning sessions with agent < 1412 are queued to repeat
it.
Event rows carry no agent version, so the split is drawn on ReceivedAt:
ResolveEraStartUtc walks the batch stamps of BOTH sides back from the newest
one and ends the era at the first gap > EraGapHours (2.0) - above any in-leg
upload gap (self-metrics every few minutes, session_stalled at 60 min idle,
maintenance sweep at 2 h), below every pre-provisioning handover. Median, grid
residual, conformity AND the distinct-batch guard now all run on the same
era-filtered sample set; the batch counters used to come from the full scan.
SessionSkewScan carries ReceivedAt per sample and its 20k cap keeps the NEWEST
samples (chunked trim) - keeping the head would have blinded the detector on
exactly the sessions large enough to reach it. The ops event reports
eraStartUtc plus the excluded per-side counts, so an inherited leg stays
visible without being alarmed on.
Tests: 3 new era facts, mutation-proven (EraGapHours = 100000 turns exactly
those three red). The handover fact also asserts that the old leg judged on its
own still fires, so it is the window that silences it, not a moved threshold.
Backend 5334 green, Agent core 2805 green, solution builds with 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zwei Fehlbefunde des 30-Tage-Reports als Warnung im Skill verankert (SignalR-Reconnect-Ratio,
Consent-Funnel aus Frontend-Events), Datenmodell korrigiert (/progress liegt auf portal.,
dcount(user_Id) ist wegen disableCookiesUsage keine Personenzahl, pageViews.duration ist 0,
exceptions-Tabelle ist leer) und die Event-Referenz vollstaendig aus dem Web-Code regeneriert.
Alle 35 Queries der Datei einzeln ausgefuehrt, 35/35 gruen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>