Closes the documented read-model follow-up of the typed API contract (51737174). Three categories:
WIRE-IDENTICAL moves/substitutions (parity facts where a literal changed, move-only otherwise): RuleDryRun + trace classes and RuleDryRunVerdict move to Shared/RulesApiModels (DryRunAnalyzeRuleResponse.Result concrete; Evidence/MatchedConditions stay heterogeneous by design); BackfillResult(+Sample)/ReclassificationResult(+Sample)/MaintenanceResult move to Shared/AdminApiModels — the shared MaintenanceJobRunResponse union envelope is split into BackfillJobRunResponse/ReclassifyJobRunResponse (identical key layout, split only for the typed slot); TenantConfigFieldSchema moves as a plain class with the record's constructor signature (Shared has no IsExternalInit — call sites just lowercase their named args); HealthCheck moves (HealthCheckResult stays in Functions, it never hits the wire); GlobalNotificationDto moves (one DTO, both notification endpoints); GetMetricsSummaryAsync builds MetricsSummaryTenantItem instead of the anonymous row ([ProjectedItems] dropped, slot concrete, substitution parity fact incl. the per-item windowDays duplicate; IMetricsRepository signature follows).
UNTYPED ENDPOINTS -> DTO (parity facts mandatory): auth/me success body becomes AuthMeResponse (15 keys in exact wire order; role null-omission fact; blocked arms stay anonymous error shapes; AuthFunctionTests.ToDynamic now exposes wire casing so asserts read camelCase for DTOs and error literals alike). auth/mcp becomes CheckMcpAccessResponse — the four fixed keys are non-nullable strings on McpAccessCheckResult, so the dictionary never emitted null values and WhenWritingNull changes nothing; the four conditional keys are nullable slots in exact insertion order, isGlobalAdmin emitted only as literal true; three facts pin plain/platform+delegated/denied against the old Dictionary literal.
ENTITY-HYGIENE (DELIBERATE wire change, exact-JSON shape pins instead of parity facts): GetGlobalAdminsResponse/AddGlobalAdminResponse serve GlobalAdminRow, GET+POST tenants/{id}/admins serve TenantAdminRow, preview/whitelist serves PreviewWhitelistTenantEntry { tenantId } — partitionKey/rowKey/eTag/timestamp (and the whitelist's garbage approvedAt/approvedBy defaults) are gone from the wire. Every wire instance was synthetically back-mapped from Shared read models (GlobalAdminEntry/TenantMember) anyway; the services now map forward into the rows and the ITableEntity types stay storage-only. Consumer audit (web+MCP) found exactly ONE reader of a table key: AdminConfigContext preview-approval (t.partitionKey -> t.tenantId, same change). Shape pins assert the full JSON and table-key absence.
Guard: TypedResponseGuardTests gains an object-slot reflection ratchet over the reachable wire-type graph (IApiResponse + [WireContract] roots, transitive property walk; [ProjectedItems] slots and dictionary values exempt) with mutation-proven red/green; baseline holds exactly RuleDryRunCondition.Evidence, and stale entries fail the ratchet-down arm.
Codegen: manifest 298->311 exported types, every former unknown slot concrete in both wire-types.generated.ts copies; generator idempotent. MCP adopts: access-guard.ts drops its inline auth/mcp cast for CheckMcpAccessResponse (defensive delegatedTenantIds normalization kept), rules.ts drops the local RuleDryRun view (the dead verdict cast disappears; matchedConditions feeds interpolateRuleTemplate typed), get_tenant_config_schema gets its envelope type. Web adopts: new types/auth.ts shim (TenantRole/HomedApp narrowing over AuthMeResponse), AuthContext.UserInfo derives from the wire type and both duplicated as-cast mapping blocks collapse into one toUserInfo(); health-check page, notification contexts and the TenantAdmin types re-export generated interfaces.
Deliberately untouched: config/all + tenants/{id}/admins stay bare arrays (typed items, envelope would break four array-typed web readers — ime-versions precedent); error bodies stay anonymous; error-body ratchet remains a separate PR; the two documented guard baseline entries stay.
Suites: Backend 5330, Agent 2805, DecisionCore 577, SummaryDialog 44, MCP tsc 0 + 617, Web tsc 0 + vitest 957 + eslint 0; solution builds warning-free.
Deploy note: backend + web + MCP all need deployment; the category-C wire change is only visible in the GA admin UI (preview-approval badges) between backend and web deploy.
Co-authored-by: Claude Fable 5 <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>
The shutdown run allowed every logged-in user (explorer.exe owner) dynamically, so a backdoor account created after the startup scan and signed in before enrollment completed was scored 0. The WMI inventory is now read first and a logged-in name that is a local account is rejected for the account and the profile check (reported as logged_in_local_accounts); only Entra/domain identities — the only ones seen in real sessions — remain dynamically allowed for their profile folder.
Built-ins are recognised by RID 500/501/503/504 on the machine SID in addition to their English names: since disabled accounts are inventoried (88dfd331), localized Gast/Invité/Administrateur would otherwise flag every non-English device. kioskUser0 (Assigned Access autologon) joins the built-in list; the profile check skips junctions and $<32 hex> folders (cannot be an account name, pure payload noise). ANALYZE-ID-002 1.3.0 explanation mentions the new field.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The path guards, the enumeration's reparse-point skip and the per-file attribute check all judged path strings, while the FileStream feeding the ZIP opened that string later (after earlier files were compressed). Under any recursive source folder a local user can write to, a subdirectory turned into a junction in that window made SYSTEM copy the junction target into the tenant-uploaded package.
PinnedSourceFolder holds each section folder open for the whole section (final path must equal the validated path, 8.3-expanded; FILE_LIST_DIRECTORY without delete share, so NTFS refuses renaming it or any ancestor) and opens each candidate with FILE_FLAG_OPEN_REPARSE_POINT, requiring the handle's final path to be exactly <canonical folder>\<relative path>; the archive is fed from that same handle. Manifest: FOLDER REJECTED (...) / SKIPPED (resolved outside validated folder) / FAILED (open). The race is pinned by a test that swaps the directory from the BeforeSourceFileOpen seam; on the previous code the same test copies the junction target.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Welcome/farewell mails rendered DomainName unencoded while the full-model
config PUT persisted it verbatim, letting a tenant admin ship attacker HTML
in a signed product mail to an arbitrary recipient. Encode at Render() and
the built-in builders, preserve DomainName from the stored row on PUT for
every caller, and only seed a strict DNS host name at first login.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EffectRunner posts ClassifierVerdictIssued from inside DecisionStepProcessor.ApplyStep,
i.e. on the SignalIngress worker thread. With the bounded channel (256) full, Post's
back-pressure slow path blocked the channel's only consumer as a producer: nothing could
ever drain, every collector Post blocked behind it, and the whole decision/monitoring
pipeline froze silently until the max-lifetime watchdog.
Post now detects a call from the worker thread itself and routes the item to a
worker-local follow-up queue that WorkerLoop drains (iteratively, ahead of the channel)
right after the current item. Same processing path — ordinals, SignalLog, emit,
reduce, ApplyStep — just never blocking. Worker posts are accepted during the Stop
drain too, since the verdict belongs to the step being finished. ReentrantPostCount
exposed for observability/tests.
Tests: full channel + worker post releases a blocked foreign producer and processes the
verdict before the next channel item (fails against the previous code by hanging);
follow-up chain drained in order without recursion; verdict posted during Stop drain is
not lost. Docs: agent/decision-engine.md, agent/overview.md, log.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agent endpoints authenticated per tenant but authorized session writes on a caller-supplied
session id. Sessions rows now carry the creating device identity (cert thumbprint + Intune
device id from the CN, or bootstrap code + serial) in primary-only Owner* columns.
SessionOwnershipPolicy evaluates register / telemetry / error-report requests against it;
SessionOwnerBindingObserver records the outcome on the request row, as a warning and as a
throttled SessionOwnerMismatch ops event. Nothing is rejected in this stage.
Agent: errorCode parsing on 401/403, one-shot session rotation on session_owner_mismatch
without feeding the auth-failure tracker, spool drop, agent_started.sessionRotated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SecurityValidator accepted certificate bytes from X-Client-Certificate when X-ARR-ClientCert was
absent. That header is client-writable, and certificate content alone carries no proof of private-key
possession — the TLS handshake does. On platform-cert-enforced routes the fallback was unreachable,
but /api/bootstrap/* is excluded from the platform cert requirement and an EMPTY X-Bootstrap-Token
passed the function gate (Headers.Contains) while the validator treated it as no token and fell
through to the certificate stage, where the fallback header was parsed (live-verified 2026-08-29).
- Only X-ARR-ClientCert is read; the platform strips inbound copies even on excluded paths.
- IsBootstrapRoute (path prefix mirroring the exclusion entry): missing/empty token or unwired
BootstrapSessionService => 401 before the certificate stage.
- GetBootstrapToken treats an empty header as absent; the three bootstrap functions use it.
- Tests: BootstrapRouteFailClosedTests. Docs: client-cert-tenant-binding.md transport section.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Granting a cross-tenant role is "type the UPN" again. AdminIdentityResolver derives the home
tenant + object id from sign-in history (UserActivity: both values came from validated tokens,
most recent sign-in wins), falls back to the UPN domain of an onboarded tenant (tid only, oid
pinned on first sign-in), and never guesses when a UPN was seen under two tenants. Only a
never-seen domain yields 422 HomeTenantUnresolved, on which the portal shows a home-tenant picker.
- IMetricsRepository.GetSignInIdentitiesByUpnAsync (filtered UserActivity scan, grant-time only)
- Grant endpoints: homeTenantId/objectId optional overrides; bindings dropped from the list bodies
- Web: identity fields and binding pills removed from the delegated / tenant-group forms;
new collapsed "Identity bindings" panel in Tenant Management -> Edit Tenant Configuration
(bindings homed in that tenant; inline edit / remove via global/identity-bindings)
- Tests: AdminIdentityResolverTests; grant validation now accepts an absent home tenant
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GlobalAdmins, DelegatedAdmins and TenantGroupAssignments resolved their role from the
upn/preferred_username claim alone while the API accepts tokens from every Entra tenant, so a
genuine token minted in a foreign tenant (re-registered domain) or by a recycled UPN in the home
tenant inherited platform admin.
- New AdminIdentityBindings table: RK = UPN -> home TenantId + ObjectId; in TableNames.All,
CriticalBackupTables and the full-restore-forbidden auth set; offboarding wipes bindings homed
in the offboarded tenant
- AdminIdentity (upn + tid + oid) is what GlobalAdminService.GetGlobalRoleAsync and
DelegatedAdminService.GetScopeAsync resolve on; order row -> binding -> Pro gate, fail-closed
(no oid / no binding / tid or oid mismatch => no role), mismatches logged at Warning
- Object id pinned on the first sign-in from the bound tenant (ETag-conditional, verified against
the stored winner); a foreign-tenant token can never claim the pin
- Grants (auth/global-admins, global/delegated-admins, tenant-group assignees) require
homeTenantId (+ optional objectId) and answer 409 instead of re-homing a bound UPN;
GET/PUT/DELETE global/identity-bindings is the audited rebind surface
- RequestContext.ObjectId; throttle identity prefers oid so shared UPN strings across tenants no
longer collide in one rate-limit bucket
- Web: home tenant / object id fields on the grant and assign forms, per-UPN binding pill
- Tests: binding verdict matrix + race semantics, middleware-level denials for foreign-tenant and
recycled-UPN tokens; OKF doc docs/backend/admin-identity-binding.md
Legacy role rows are inert until their binding is seeded - seed before deploying the backend.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Microsoft serves IME from several versionless hosts carrying different rings at once
(secondary = 1.104.102.0 while hotfix = 1.105.103.0), so "new version == what the
canonical URL serves" filed a 1.104 package under 1.105.103.0. The archiver now spools
each candidate, reads ProductVersion with a managed CFB/MSI reader (Linux host, no
msiexec) and uploads only a match; no match ends as Failed:VersionMismatch. A later
sighting with a version-authoritative CSP URL re-queues an unarchived version at most
once per 24 h (MsiArchiveUpdatedAt + Queued stamp).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The admin Data Sync Status only offered KEV and MSRC; the NVD stale-row refresh and the EPSS
re-score ran on the nightly timer alone. Both walks move out of the timer into
VulnerabilityCacheRefreshService (singleton, one in-flight slot per walk) so the buttons and the
nightly run share one code path and stamp the same timestamps (NvdCacheLastRefreshUtc — only for
a walk that reached every stale row; EpssLastSyncUtc). POST vulnerability/sync-nvd starts the
minutes-long, rate-limited NVD walk in the background and answers 202 (409 while running); the
page polls sync-status (nvdRefreshRunning, nvdLastRun) every 10 s meanwhile. POST
vulnerability/sync-epss runs synchronously and returns the counts. The timer skips a slot a
manual run holds instead of queueing a duplicate. Shared manifest regenerated for the two new
AdminConfiguration fields.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EPSS: EpssApiClient (api.first.org, 100 ids per batch, unauthenticated, fail-soft). Score, percentile
and date live on the cached CVE — one batch per CPE fill, every cve_* row re-scored by the nightly
sync, only changed rows rewritten and with their original CachedAt. CveIndex carries EpssScore (max,
absent when unscored) and Priority; TopCve exposes both. CvePriority: act = CISA KEV, attend =
EPSS >= 0.1 or CVSS >= 9.0, track = rest — riskLevel/overall_risk semantics unchanged.
NVD throttle: Polly already retried 429s; the defect was that a refused answer came back as an
empty response and was cached as "0 CVEs" for seven days (also the negative auto-resolve cache).
NvdFetchResult separates answered from refused; nothing persists a refusal; a 429 opens a
process-wide cooldown (Retry-After capped at 120 s, else 30 s) so a run fails fast instead of
paying a retry chain per software item; hard-expired rows keep serving meanwhile. The NVD->cache
projection and the row builder are now single functions shared by the live fill and the sync.
CVSS v3 vector string persisted and shown. Portal: EPSS column + pill, priority pill, vector line;
exposure panel pills. MCP tool descriptions and the CVE-audit prompt name the fields. Terms
attribution + privacy page updated in the same change. OKF doc backend/vulnerability-correlation-sources.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire protocol: replace @modelcontextprotocol/sdk 1.30 with @modelcontextprotocol/server + /node 2.0.0
(zod 4). One per-request factory (mcp-server-factory.ts, extracted from index.ts) serves both eras
through mcp-http.ts: 2026-07-28 clients via createMcpHandler (legacy:'reject', JSON response mode,
server/discover, Mcp-Method routing), 2025 clients through the existing stateless transport with
enableJsonResponse — routed on the SDK's own isLegacyRequest predicate, because the built-in stateless
fallback would answer every current client with SSE and lose the gzip win. Capabilities now declare
listChanged:false explicitly; cacheHints stamp ttlMs/cacheScope:'private' on discover/list results
(5 min) and resource reads (60 min) — the catalog is role-dependent, so never public.
Authorization: advertise client_id_metadata_document_supported; an https client_id is a Client ID
Metadata Document resolved by cimd.ts (URL-shape checks, SSRF gate on every resolved address before any
request, no redirects, 5 s / 16 KB caps, exact client_id match, bounded redirect_uris, 10-60 min cache).
The requested redirect_uri still has to pass the dynamic-registration allowlist; HMAC-signed DCR
client_ids remain the fallback. Field limits moved to oauth-limits.ts (no import cycle).
Tests: mcp-http.test.ts drives the real v2 client over loopback HTTP in both eras and pins role parity;
cimd.test.ts / oauth-cimd.test.ts cover recognition, SSRF, validation, cache and the router paths.
Package 1.5 -> 1.6. OKF doc docs/mcp/protocol-eras-and-cimd.md; mcp-oauth-flow.md updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CmTraceTimeSkewRegression fired 7x in its first soak week; every checked case
was a relaunched agent re-tailing the IME log and uploading one burst holding
most of the session's IME samples with a continuum of ages whose median hit the
15-minute grid by chance. The tripwire now also requires >= 80 % of the IME
deltas (relative to the other side's median) to individually sit within 2 min of
some grid multiple; era-mixing regressions still fire. Fraction is carried in the
ops event message and details.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Microsoft released Windows Autopilot device association on 2026-08-25. The
DevPrep association lookup leaves shadow mode and becomes an accepting method
of the device-validation gate (after Autopilot and Corporate Identifier, before
the Cloud PC fallback; ValidatedBy = DeviceAssociation, transient -> 503). The
toggle is no longer Global-Admin-only: backend PUT revert and GaOnlyFields
dropped, settings toggle shown to every tenant admin under Device Preparation
with the shared consent rule. Landing hero carries a release pill linking to
the docs; dashboard tip added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First real calibration read (2026-08-27, four days instrumented) drove three
data-backed changes:
- Rule 5a: past grace (or with the agent provably gone), Device Setup done +
real-user desktop + zero app_install_failed reconciles to Succeeded
"completed (assumed)" (r5_assumed). r5-Incomplete devices re-enrolled at
2.6% - the succeeded background, not the ~29% failure level - and the
sampled desktop half had clean app records. No desktop or any failed app
stays Incomplete; rule 4 remains the immediate desktop+Hello reconcile.
- Rule 5 skips the AwaitingUser grace once either max-lifetime watchdog shape
(enrollment_failed/agent_timeout, agent_shutting_down/max_lifetime) is in
the stream: all five observed maxlife:r5_awaiting parks expired unhealed
while live-agent parks upgraded 7/26. The maxlife ingest appends its
trigger event to the read stream so the fact is never missed; late
telemetry still heals a terminal verdict. WhiteGlove rule 1b keeps its
park (its one episode healed).
- Drift radar: MinWindowHits raised to 10 (a 3-hit episode survived as
noise), per-path share regression now needs >=5 baseline hits (new or
renamed vocabulary cannot "regress"), and rule-shaped legacy:* paths count
into the silence/classifier-verdict group sums on both sides of the
2026-08-23 instrumentation cutover - dissolving the lift-124 silence
artifact. Episodes below the fire floors clear themselves.
Backend only; the new path appears in the calibration matrix dynamically.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A delivery log names only the recipient, never the tenant the welcome mail was
for. The tenant console's search now matches the notification and contact
address as well as domain and tenant id; the quoted exact mode is unchanged, and
no other address is searchable (an admin UPN never receives platform mail and
would only widen the PII surface of the list).
The addresses live in PreviewWhitelist, not on the tenant config, so a new
GA/Reader-only GET /api/preview/notification-emails returns them as one map --
a single cross-partition scan instead of a point read per tenant. Both sides
join on the lowercased tenant id (address rows carry the JWT tid, a config
PartitionKey's casing is not guaranteed) and matching is case-insensitive,
because stored addresses keep the casing the user typed.
The map is best-effort in the portal: a failed read costs only the
search-by-address, never the tenant list. Addresses stay out of the list rows --
they are a search key, and remain visible in the tenant editor.
Match rules moved out of the component into tenantSearch.ts with unit pins, and
a catalog test pins that the plural route does not resolve to the per-tenant
entry one character away (which would demand a tenantId the route does not
carry, and hand a delegated caller a rescue path into the whole map).
Deploy: backend before web.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The timeline showed WiFi segments with an SSID and no signal, and an "Avg WiFi
signal" stat that simply vanished — on a location-gated device that reads as a
product defect rather than the Windows 24H2 gate that caused it.
No agent change was needed: buildSegments already merges every wifi_signal_info
into the WiFi segment covering its timestamp, so wifiDataLimitedReason rides in
with the sample that was already being read.
- NetSegment.dataLimitedReason, filled from the sample.
- Segments that never got a sample of their own (a mid-session reconnect emits
no wifi_signal_info) inherit it: the gate is a device-level setting and cannot
flip during an enrollment. A segment WITH a real reading is never overwritten
— pinned by a test.
- Tooltip: "Signal: unavailable — Location services off on this device" where
the percentage would be.
- Stats row: "Avg WiFi signal: n/a (Location services off)" instead of dropping
the row.
network_state_change is deliberately left alone. That event has never carried
signalPercent or radioType — NetworkChangeDetector collects them into its
snapshot for change detection, but only before_/after_wifiSsid reaches the
payload — so there is no absent field there for a reason to explain, and the
SSID it does carry is unaffected by the gate. Adding it would ship a field
nothing reads.
Verified: web tsc + eslint (0 warnings) + vitest 893/893.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A missing WiFi signal reading is indistinguishable from a bug unless the payload
says why, and the device-level location setting is not something an admin would
think to check. Since 058248d9 the SSID survives the gate via the WinRT tier —
but signal, radio and channel silently vanish with no explanation anywhere.
When the WinRT tier wins AND the native tier reported rc=5, wifi_signal_info now
carries wifiDataLimitedReason = "location_services_off" and the event message
gains "— signal unavailable, Location services off", so the reason reads straight
off the timeline. The portal's WiFi card matches that literal and names the
Intune setting that restores the full payload.
The claim is evidence-only: any other native failure (no WLAN service, no
connected interface) leaves the field null rather than blaming a privacy setting
we did not observe. IsLocationGateDenied is the single predicate, pinned at its
boundaries (rc=5 yes; rc=50, rc=5023, rc=1062 no), and the literal is pinned as
the published agent/portal/MCP contract.
New wifi_signal_info group in the MCP DEVICE_PROPERTIES_CATALOG, so a fleet can
be sized before anyone changes a policy:
search_sessions(deviceProperties: {
"wifi_signal_info.wifiDataLimitedReason": "location_services_off" })
Backend is untouched — the event payload is opaque DataJson with no projection.
network_state_change and the network timeline deliberately do not carry the
reason yet; that is a second payload plus web change for no extra insight.
Verified: build 0 warnings, agent suite 2553/2553, web tsc + eslint (0 warnings)
+ vitest 889/889, MCP tsc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wifi_signal_info was missing on most Windows 11 24H2+ WiFi enrollments
(build 22631 3/3, 26100 37/178, 26200 43/95 over n=276 sessions since 08-01).
Not a parser or language problem: every event that exists carries a full SSID,
so the failure is binary at the reader level.
Since 24H2 Windows gates WlanQueryInterface with
wlan_intf_opcode_current_connection — the only opcode we use — behind
precise-location consent and returns ERROR_ACCESS_DENIED. The consent prompt
only fires for a process in the user's context outside System32, so the agent
(SYSTEM, session 0, before the OOBE privacy page) is denied silently, and it
resolves consent from the HKLM ConsentStore because the S-1-5-18 hive has none
— while the interactive user on the same machine reads the SSID fine via the
per-user NonPackaged value. That is why it tracks the image's location setting,
not hardware, driver or language.
netsh wlan hangs off the same gate, so the 2026-07 netsh fallback and its
multi-language labels never covered the case they were built for; more labels
cannot fix it.
- New WinRtWifiSsidReader: Microsoft's documented replacement
WlanConnectionProfileDetails.GetConnectedSsid (ungated, SSID only), bound via
the CLR WinRT projection — no winmd, no NuGet, no csproj change — the same
mechanism OobeStateReader already uses from the SYSTEM context.
- New WifiInfoProvider owns the tier order (native -> WinRT -> netsh) that both
collectors had duplicated.
- The step diagnostics added in 2026-07 were logged at Debug while the default
level is Info, so they never reached a field log. The all-tiers-failed case is
now a Warning that decodes rc=5 into the location-gate explanation.
Signal, PHY and channel have no ungated source and stay null on tier 2;
deriving a percentage from GetSignalBars would fabricate precision.
Verified: build 0 warnings, agent suite 2542/2542, interop struct sizes measured
live (532/604), and the full WinRT chain resolves and returns the SSID under
net48.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Intune roots the chain is pinned to are shared by every Intune tenant, so
a valid certificate only ever proved "issued by Intune to someone". Tenant
scoping rested on the device serial, which is printed on the chassis. A
Mismatch now returns 403.
Switched on against measured data, not a hunch: 35,684 requests across 32
tenants since the shadow telemetry landed, every one a Match, with the
Warning channel independently verified to be carrying other traffic so the
zero could not be a telemetry artefact.
Only Mismatch blocks, and the rule lives in one function so "what rejects a
request" has a single answer. ExtensionMissing, Unparseable and
RequestTenantNotAGuid mean "cannot tell" and are tolerated: the sample only
covers devices active in the window, so an older certificate or a
sovereign-cloud CA could still lack the extension, and rejecting on absence
would lock those devices out for something outside their control.
ExtensionMissing is the candidate for later tightening and has its own test
pinning the tolerance, so removing it must be a decision.
Evaluation fails open on its own exception — a defect on our side is not
evidence of a cross-tenant certificate. The 403 body does not echo which
tenant the certificate belongs to; that pair goes to the rejection log.
Device binding (stage 3) is untouched: still shadow, still Global-Admin-only.
Backend 4638/4638, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IME never writes its "Completed user session" line when the user has no
assigned Win32 apps - its zero-app branch returns before that statement
(ApplicationPoller.cs:713-721, identical in 1.80/1.83/1.97/1.104). So
ImeUserSessionCompletedUtc stayed null, every arm of
ShouldTransitionToAwaitingHello was unsatisfiable, and the 30-min
advisory_completion backstop resolved healthy enrollments as
esp_exit_without_completion_evidence. One tenant: 10 of 30 sessions in a week,
all false positives - DAD-validated desktop, Hello provisioned, RealmJoin still
installing. Every Succeeded session there carries ime_user_session_completed;
not one of the failed ones does.
IME-USER-SESSION-ZERO-APPS reads the line IME does write there and routes it
through the same OnUserSessionCompleted callback. That is IME's own verdict
rather than a weakened gate: the zero-app branch locks the ESP in with an empty
application list, and the sibling emit site returns ProvisioningComplete right
after logging it. Guarded to the AccountSetup phase so it cannot burn the
adapter fire-once flag on a pre-sign-in timestamp, and replayed on the phase
transition when the observation predates the phase marker. AreUserEspAppsSettled
accepts an empty app list only with that explicit observation, never on
emptiness alone.
Second, independent defect: advisory_completion (30 min) overran the RealmJoin
completion gate, which deliberately holds the session and carries its own
bounded resolution (60 min from detection, activity-extended, 4 h cap). One
session installed 25 RealmJoin packages inside the window, the last 2 min before
the failure, while completion_waiting still read "waiting on:
realmjoin_resolution". The handler now re-arms while that gate is closed and its
own deadline is still armed; convergence comes from the gate itself, which
always ends with an Outcome set.
ime_user_session_completed gains an evidence field (zero_user_apps_enumerated /
user_session_completed) - no new event type, so the Constants ->
resource-catalog -> shared-manifests triple stays untouched. The pattern ships
via the config API and reaches existing agents, where an unknown action is a
single Debug line; the behaviour needs the agent release.
Build 0 warnings. DecisionCore 558/558, V2.Core 2530/2530, Backend 4634/4634,
MCP event-types-drift 6/6.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same gate the cert-device-binding shadow got in 35a3ed0c, applied to its
sibling. Measured on the live enrollment: 263 identical shadow lines for one
device off a handful of Graph calls, because the cache answered nearly every
request and the log sat outside it.
DeviceAssociationResult.AsCacheHit() copies rather than marking the shared
cached instance, so the first real lookup is never mistaken for a cache hit
and dropped. Transient outcomes are not cached and keep logging every time.
Backend 4633/4633, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on the first live enrollment: 136 agent requests produced exactly
ONE Graph call, because the 30-minute positive cache absorbed the rest. So
the check is not a throttling risk (the per-request Autopilot serial
validator made 25 calls in the same window) and the positive TTL needs no
change — raising it would only delay noticing a wiped or removed device.
What the 136 requests did cost was 136 log lines all saying the same thing.
Gate the shadow log on ServedFromCache so it emits one line per real lookup.
Cached repeats carry no new information: the reported age just counts up
mechanically from the same enrolledDateTime. Outcomes that are never cached
(Transient, NoDeviceIdInCert) keep logging every time, which is what an
anomaly warrants.
AsCacheHit() copies rather than marking the shared cached instance, so the
first real lookup is never mistaken for a cache hit and dropped.
Backend 4631/4631, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tenant binding (stage 1) proves the client certificate was issued to this
tenant. It does not prove the certificate belongs to a device the tenant
still has: a cert lifted from a decommissioned machine keeps working.
IntuneDeviceBindingValidator resolves the Intune managedDevice id from the
certificate's Subject CN against the tenant's own managedDevices inventory.
Point-GET on managedDevices/{id} rather than an OData filter, so there is no
filter literal to inject into and a 404 is unambiguous. Cache/retry/transient
contract mirrors CloudPcDeviceValidator.
Shadow only, and visible only to Global Admins (same gate as the DevPrep
association preview) because the open question is a race, not an attack: a
device object can appear after the agent's first call. Every result carries
enrolledDateTime and the log line records enrolledAgeSeconds, so a device
created seconds before the request is distinguishable from a foreign cert.
Grep marker: CERT-DEVICE-BINDING-SHADOW.
The lookup is fire-and-forget like the DevPrep shadow so a cold Graph
round-trip never sits in the agent request path. That rules out stage 1's
request-row dimension (the row is written before the task finishes), hence
the Warning log; widening beyond preview should move to inline + dimension.
Needs DeviceManagementManagedDevices.Read.All, granted per tenant via the
customer script as feature IntuneDeviceBinding. The script's catalog,
ValidateSet and help block stay pinned to GraphFeatureCatalog by
GraphFeatureCatalogSyncTests (extended with the help-block case).
Build 0 warnings, backend 4629/4629, web 874/874, tsc clean, eslint 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Post-deploy verification of 249e39bc found zero AgentCertTenantBinding rows
across 32k agent requests. Cause: the Match outcome was emitted with
LogInformation, and worker-side ILogger below Warning never reaches App
Insights (provider default rule is Warning+, host forwarding of worker logs
is off). The bulk outcome was therefore invisible, which is indistinguishable
from the code never running — the shadow telemetry had no denominator.
Stamp every outcome onto the request row instead (FunctionContext.Items ->
RequestTelemetryMiddleware -> CertTenantBinding dimension). That row already
exists per request and is unsampled, so the denominator costs no extra
telemetry. Non-Match outcomes keep their Warning trace with full context.
FromCache existed only to sample the dead Match line and is removed with it.
Backend 4606/4606, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A chain-valid Intune MDM certificate only proves "issued to some tenant" —
the pinned roots are shared by every Intune tenant. Tenant scoping rested
entirely on the device serial in the downstream validators, which is not a
secret. Bind the tenant stamped into the auth cert to the requested tenant.
The Entra tenant id sits in OID 1.2.840.113556.5.14 of the cert the agent
already presents for mTLS, so this needs no Graph call, no consent and no
agent release. Not 5.6 (Intune Account id, a different GUID) and not 5.4
(device id, stored as 16 bare bytes) — the decoder handles both encodings
and now lives once in Shared, used by agent and backend.
Stage 1 observes only: ObserveCertTenantBinding returns void and swallows
its own exceptions, so it cannot fail an otherwise valid request. Match is
logged only on cert-cache miss, keeping the denominator without per-request
volume. Grep marker for stage 2: CERT-TENANT-BINDING-SHADOW.
Backend 4604/4604, Agent V2.Core 2519/2519, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Live-verified after 0d5e2572 deploy: the worker filter removes every 404/412/409
and worker SignalR row, but the SignalR output-binding calls are emitted by the
host process (SDK rdddsc) and only reachable at ingestion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
StartupTelemetryService emits BackendStartupMs (process start ->
ApplicationStarted) and BackendTableInitMs as App Insights custom
metrics with version/commit/tableInitFullPass/instance dimensions;
TableStorageService exposes LastInitialization. MCP server writes one
{"type":"boot","readyMs"} stderr line per start next to the
tool_call lines so cold-start time is queryable as a trend.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cold start point-reads a SHA-256 over TableNames.All stored in
AdminConfiguration/SchemaSentinel/tables instead of issuing 66
CreateTableIfNotExists calls. Missing/stale sentinel runs the full
idempotent pass once and rewrites it. SessionsIndex startup backfill
only after a full pass, behind a conditional-insert claim (one instance
under scale-out); IsSessionIndexEmptyAsync no longer treats errors as
empty. Daily maintenance runs IStorageInitializer.EnsureAllAsync to
repair out-of-band table deletions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop per-row window7/baseline28.sessions (duplicated trend denominators), make
reEnrollRatePct/lift always present with null = withheld, add minSharePct/top
tail trimming that never drops override-carrying rows and reports what was
omitted. Backend wire shape and admin page untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First production pass alerted on agent:whiteglove_pending (two tenants in a
WhiteGlove rollout week) and agent:complete_soft (a Continue-Anyway tenant) —
customer workflow mix, not classifier drift. agent:* and register:* are now
ineligible for share_regression (still in the matrix and the silence group);
episodes opened by the broader gate re-arm on the next pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>