feat(backend,mcp,web): typisierung follow-up — the deliberately-untyped remainder is typed end-to-end, and the raw table entities leave the wire (#137)

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>
This commit is contained in:
Oliver Kieselbach
2026-08-31 15:22:16 +02:00
committed by GitHub
parent 51737174b2
commit eaef1601d0
50 changed files with 2606 additions and 652 deletions
+12
View File
@@ -68,6 +68,18 @@ sites the 2026-08-13 audit froze). Three layers enforce it:
- **`*WireParityTests`**: each migrated site carries an ordinal old-anonymous-literal
vs. new-DTO serialization proof (production `ApiJsonOptions`), including a null case
per key that WhenWritingNull omits.
- **Object-slot ratchet** (2026-08-31 typisierung follow-up): a reflection walk over
the reachable wire-type graph fails on any `object` / collection-of-`object`
property outside `ObjectSlotBaseline` (sole entry: `RuleDryRunCondition.Evidence`,
heterogeneous by design). `[ProjectedItems]` slots and dictionary VALUES are exempt.
The former deliberately-untyped slots (RuleDryRun trace, HealthCheck,
GlobalNotificationDto, TenantConfigFieldSchema, maintenance run reports,
MetricsSummary items, auth/me, auth/mcp) are concrete Shared types now. The raw
table entities left the wire entirely in the same pass: global-admins,
tenant-admins and preview-whitelist serve flat rows (`GlobalAdminRow` /
`TenantAdminRow` / `PreviewWhitelistTenantEntry`) WITHOUT
partitionKey/rowKey/eTag/timestamp — a deliberate wire change pinned by exact-JSON
shape tests instead of parity facts.
The DTO rules: declaration order == wire order; a property is nullable exactly when a
site can emit null (the key then vanishes — never add non-null defaults that would
+4
View File
@@ -1,5 +1,9 @@
# Log
## 2026-08-31 (2)
* **Update**: `drift-guards.md` — typisierung follow-up: the deliberately-untyped remainder (RuleDryRun trace, HealthCheck, GlobalNotificationDto, TenantConfigFieldSchema, maintenance run reports, MetricsSummary items, auth/me, auth/mcp) is typed end-to-end; a new object-slot reflection ratchet in `TypedResponseGuardTests` closes the class (baseline: `RuleDryRunCondition.Evidence` only). Deliberate wire change: global-admins / tenant-admins / preview-whitelist rows are flat read models (`GlobalAdminRow` / `TenantAdminRow` / `PreviewWhitelistTenantEntry`) without partitionKey/rowKey/eTag/timestamp, pinned by exact-JSON shape tests.
## 2026-08-31 (1)
* **Update**: `drift-guards.md` — the shrink-only OkAsync baseline became the typed API contract (`feat/typed-api-contract`): all 175 anonymous success bodies (44 helper + 134 raw minus overlaps already typed) are flat `IApiResponse` DTOs in Shared, both `TypedResponseGuardTests` baselines are empty regression guards, the untyped `OkAsync/CreatedAsync(object)` overloads are gone, and every migrated site carries an ordinal wire-parity proof. `SharedManifestParityTests` schemaVersion 2 exports the full type graph (208 types incl. `[WireContract]` extras) with C# `<summary>` JSDoc into `utils/wire-types.generated.ts`; web hand-types re-export from it; freshness pinned by vitest + the new `shared-manifests-in-sync` CI job.
@@ -305,9 +305,9 @@ public class AdminWireParityTests
// ---- BackfillOccurredUtc / ReclassifyLegacySessions (shared envelope) ----------------
[Fact]
public void MaintenanceJobRunResponse_matches_the_backfill_shape()
public void BackfillJobRunResponse_matches_the_backfill_shape()
{
var result = new OccurredUtcBackfillService.BackfillResult
var result = new BackfillResult
{
Table = "audit",
DryRun = true,
@@ -330,7 +330,7 @@ public class AdminWireParityTests
triggeredBy = userEmail,
triggeredAt,
},
new MaintenanceJobRunResponse
new BackfillJobRunResponse
{
Success = true,
Result = result,
@@ -340,9 +340,9 @@ public class AdminWireParityTests
}
[Fact]
public void MaintenanceJobRunResponse_matches_the_reclassify_shape()
public void ReclassifyJobRunResponse_matches_the_reclassify_shape()
{
var result = new LegacyReclassificationService.ReclassificationResult
var result = new ReclassificationResult
{
Mode = "legacy_timeouts",
DryRun = false,
@@ -368,7 +368,7 @@ public class AdminWireParityTests
triggeredBy = userEmail,
triggeredAt,
},
new MaintenanceJobRunResponse
new ReclassifyJobRunResponse
{
Success = true,
Result = result,
@@ -768,14 +768,15 @@ public class AdminWireParityTests
}
// ---- TenantAdminManagement (AddTenantAdmin) ------------------------------------------
// DELIBERATE wire change (2026-08-31 entity-hygiene pass): the row is TenantAdminRow, not
// the raw TenantAdminEntity — partitionKey/rowKey/eTag/timestamp are gone from the wire.
// These pins fix the NEW shape exactly (key names, order, table-key absence).
[Fact]
public void TenantAdminCreatedResponse_matches_the_created_member_shape()
public void TenantAdminCreatedResponse_pins_the_created_member_shape()
{
var newAdmin = new TenantAdminEntity
var newAdmin = new TenantAdminRow
{
PartitionKey = SampleTenantId,
RowKey = "operator@contoso.com",
TenantId = SampleTenantId,
Upn = "operator@contoso.com",
IsEnabled = true,
@@ -784,9 +785,46 @@ public class AdminWireParityTests
Role = "Operator",
};
AssertParity(
new { admin = newAdmin },
new TenantAdminCreatedResponse { Admin = newAdmin });
Assert.Equal(
"{\"admin\":{"
+ $"\"tenantId\":\"{SampleTenantId}\","
+ "\"upn\":\"operator@contoso.com\","
+ "\"isEnabled\":true,"
+ "\"addedDate\":\"2026-08-30T14:00:00Z\","
+ "\"addedBy\":\"admin@contoso.com\","
+ "\"role\":\"Operator\","
+ "\"canManageBootstrapTokens\":false}}",
TestWire.Serialize(new TenantAdminCreatedResponse { Admin = newAdmin }));
}
[Fact]
public void TenantAdminRow_omits_a_null_role_and_never_emits_table_keys()
{
// Legacy pre-role rows have Role = null → the key vanishes (WhenWritingNull), and the
// ITableEntity keys of the storage entity must never reappear on the wire.
var row = new TenantAdminRow
{
TenantId = SampleTenantId,
Upn = "legacy@contoso.com",
IsEnabled = true,
AddedDate = new DateTime(2026, 8, 30, 14, 5, 0, DateTimeKind.Utc),
AddedBy = "admin@contoso.com",
Role = null,
};
var json = TestWire.Serialize(row);
Assert.Equal(
$"{{\"tenantId\":\"{SampleTenantId}\","
+ "\"upn\":\"legacy@contoso.com\","
+ "\"isEnabled\":true,"
+ "\"addedDate\":\"2026-08-30T14:05:00Z\","
+ "\"addedBy\":\"admin@contoso.com\","
+ "\"canManageBootstrapTokens\":false}",
json);
Assert.DoesNotContain("partitionKey", json);
Assert.DoesNotContain("rowKey", json);
Assert.DoesNotContain("eTag", json);
Assert.DoesNotContain("timestamp", json);
}
// ---- TenantGroupManagement -----------------------------------------------------------
@@ -435,7 +435,7 @@ public class AuthFunctionSideEffectTests
{
_tenantAdminsMock
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
.ReturnsAsync(new TenantAdminEntity());
.ReturnsAsync(new TenantAdminRow());
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
@@ -473,7 +473,7 @@ public class AuthFunctionSideEffectTests
{
_tenantAdminsMock
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
.ReturnsAsync(new TenantAdminEntity());
.ReturnsAsync(new TenantAdminRow());
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
@@ -471,7 +471,8 @@ public class AuthFunctionTests
// -------------------------------------------------------------------------
/// <summary>
/// Converts anonymous object to dynamic for property access in tests.
/// Exposes a body (typed DTO or anonymous error shape) as dynamic under its WIRE key names
/// (camelCase), so the asserts read the same casing a client would.
/// </summary>
private static dynamic ToDynamic(object obj)
{
@@ -479,7 +480,7 @@ public class AuthFunctionTests
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
foreach (var prop in type.GetProperties())
{
dict[prop.Name] = prop.GetValue(obj);
dict[System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(prop.Name)] = prop.GetValue(obj);
}
return dict;
}
@@ -224,23 +224,23 @@ public class ConfigWireParityTests
IReadOnlyList<TenantConfigFieldSchema> schema = new List<TenantConfigFieldSchema>
{
new TenantConfigFieldSchema(
Name: "contactEmail",
Type: "string",
Format: "email",
Nullable: true,
Writable: true,
Reason: null,
GaOnly: false,
RevertProtected: false),
name: "contactEmail",
type: "string",
format: "email",
nullable: true,
writable: true,
reason: null,
gaOnly: false,
revertProtected: false),
new TenantConfigFieldSchema(
Name: "homedAppClientId",
Type: "string",
Format: null,
Nullable: true,
Writable: false,
Reason: "system-owned",
GaOnly: true,
RevertProtected: true),
name: "homedAppClientId",
type: "string",
format: null,
nullable: true,
writable: false,
reason: "system-owned",
gaOnly: true,
revertProtected: true),
};
AssertParity(
@@ -16,6 +16,107 @@ namespace AutopilotMonitor.Functions.Tests;
/// </summary>
public class InfrastructureWireParityTests
{
// ---- AuthFunction: auth/me success body ----------------------------------------------
// Left side: the exact anonymous literal BuildAuthResult produced before AuthMeResponse.
[Fact]
public void AuthMeResponse_matches_the_success_shape()
{
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var upn = "admin@contoso.com";
var displayName = "Admin User";
var objectId = "0b6f7a37-1111-4d61-9c93-0aa111111111";
var managedTenantIds = new[] { "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" };
AssertParity(
new
{
tenantId,
upn,
displayName,
objectId,
isGlobalAdmin = false,
isGlobalReader = false,
isTenantAdmin = true,
isDelegated = true,
delegatedTenantIds = managedTenantIds,
role = "Admin",
canManageBootstrapTokens = true,
hasMcpAccess = true,
homedApp = "primary",
bootstrapTokenEnabled = true,
unrestrictedModeEnabled = false
},
new AuthMeResponse
{
TenantId = tenantId,
Upn = upn,
DisplayName = displayName,
ObjectId = objectId,
IsGlobalAdmin = false,
IsGlobalReader = false,
IsTenantAdmin = true,
IsDelegated = true,
DelegatedTenantIds = managedTenantIds,
Role = "Admin",
CanManageBootstrapTokens = true,
HasMcpAccess = true,
HomedApp = "primary",
BootstrapTokenEnabled = true,
UnrestrictedModeEnabled = false
});
}
[Fact]
public void AuthMeResponse_omits_a_null_role()
{
// A roleless caller (e.g. pure GlobalReader): role was null in the anonymous literal,
// so the key vanished — the DTO must vanish it identically.
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var upn = "reader@contoso.com";
var displayName = "Reader User";
var objectId = "0b6f7a37-2222-4d61-9c93-0aa222222222";
string? role = null;
AssertParity(
new
{
tenantId,
upn,
displayName,
objectId,
isGlobalAdmin = false,
isGlobalReader = true,
isTenantAdmin = false,
isDelegated = false,
delegatedTenantIds = Array.Empty<string>(),
role,
canManageBootstrapTokens = false,
hasMcpAccess = true,
homedApp = "legacy",
bootstrapTokenEnabled = false,
unrestrictedModeEnabled = false
},
new AuthMeResponse
{
TenantId = tenantId,
Upn = upn,
DisplayName = displayName,
ObjectId = objectId,
IsGlobalAdmin = false,
IsGlobalReader = true,
IsTenantAdmin = false,
IsDelegated = false,
DelegatedTenantIds = Array.Empty<string>(),
Role = null,
CanManageBootstrapTokens = false,
HasMcpAccess = true,
HomedApp = "legacy",
BootstrapTokenEnabled = false,
UnrestrictedModeEnabled = false
});
}
// ---- AuthFunction: IsGlobalAdmin -----------------------------------------------------
[Fact]
@@ -40,26 +141,111 @@ public class InfrastructureWireParityTests
new IsGlobalAdminResponse { IsGlobalAdmin = isAdmin, Upn = null });
}
// ---- AuthFunction: GetGlobalAdmins ---------------------------------------------------
// ---- McpUserFunction: CheckMcpAccess (auth/mcp) --------------------------------------
// Left side: the exact Dictionary<string, object?> the site built before the DTO —
// insertion order == wire order, conditional keys only present when the tier applies.
[Fact]
public void GetGlobalAdminsResponse_matches_the_entity_listing_shape()
public void CheckMcpAccessResponse_matches_the_plain_user_shape()
{
var admins = new List<GlobalAdminEntity>
var payload = new Dictionary<string, object?>
{
new GlobalAdminEntity
["allowed"] = true,
["upn"] = "analyst@contoso.com",
["accessGrant"] = "whitelist",
["reason"] = "whitelisted",
};
AssertParity(
payload,
new CheckMcpAccessResponse
{
Allowed = true,
Upn = "analyst@contoso.com",
AccessGrant = "whitelist",
Reason = "whitelisted",
});
}
[Fact]
public void CheckMcpAccessResponse_matches_the_platform_and_delegated_shape()
{
var delegatedTenantIds = new[] { "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d" };
var payload = new Dictionary<string, object?>
{
["allowed"] = true,
["upn"] = "admin@contoso.com",
["accessGrant"] = "global-admin",
["reason"] = "platform role",
["isGlobalAdmin"] = true,
["globalRole"] = "GlobalAdmin",
["delegatedTenantIds"] = delegatedTenantIds,
["delegatedRole"] = "DelegatedAdmin",
};
AssertParity(
payload,
new CheckMcpAccessResponse
{
Allowed = true,
Upn = "admin@contoso.com",
AccessGrant = "global-admin",
Reason = "platform role",
IsGlobalAdmin = true,
GlobalRole = "GlobalAdmin",
DelegatedTenantIds = delegatedTenantIds,
DelegatedRole = "DelegatedAdmin",
});
}
[Fact]
public void CheckMcpAccessResponse_matches_the_denied_shape()
{
// 403 body: same shape, allowed=false, no conditional keys. isGlobalAdmin must never
// appear as false — the DTO's null slot omits it exactly like the old dictionary did.
var payload = new Dictionary<string, object?>
{
["allowed"] = false,
["upn"] = "user@contoso.com",
["accessGrant"] = "",
["reason"] = "not whitelisted",
};
AssertParity(
payload,
new CheckMcpAccessResponse
{
Allowed = false,
Upn = "user@contoso.com",
AccessGrant = "",
Reason = "not whitelisted",
IsGlobalAdmin = null,
GlobalRole = null,
DelegatedTenantIds = null,
DelegatedRole = null,
});
}
// ---- AuthFunction: GetGlobalAdmins / AddGlobalAdmin ----------------------------------
// DELIBERATE wire change (2026-08-31 entity-hygiene pass): rows are GlobalAdminRow, not the
// raw GlobalAdminEntity — partitionKey/rowKey/eTag/timestamp are gone from the wire.
// These pins fix the NEW shape exactly (key names, order, table-key absence).
[Fact]
public void GetGlobalAdminsResponse_pins_the_row_listing_shape()
{
var admins = new List<GlobalAdminRow>
{
new GlobalAdminRow
{
RowKey = "admin@contoso.com",
Timestamp = new DateTimeOffset(2026, 8, 30, 10, 0, 0, TimeSpan.Zero),
Upn = "admin@contoso.com",
IsEnabled = true,
AddedDate = new DateTime(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc),
AddedBy = "root@fabrikam.com",
Role = "GlobalAdmin",
},
new GlobalAdminEntity
new GlobalAdminRow
{
RowKey = "reader@fabrikam.com",
Upn = "reader@fabrikam.com",
IsEnabled = false,
AddedDate = new DateTime(2026, 8, 2, 9, 0, 0, DateTimeKind.Utc),
@@ -68,19 +254,24 @@ public class InfrastructureWireParityTests
},
};
AssertParity(
new { admins },
new GetGlobalAdminsResponse { Admins = admins });
var json = TestWire.Serialize(new GetGlobalAdminsResponse { Admins = admins });
Assert.Equal(
"{\"admins\":["
+ "{\"upn\":\"admin@contoso.com\",\"isEnabled\":true,\"addedDate\":\"2026-08-01T09:00:00Z\",\"addedBy\":\"root@fabrikam.com\",\"role\":\"GlobalAdmin\"},"
+ "{\"upn\":\"reader@fabrikam.com\",\"isEnabled\":false,\"addedDate\":\"2026-08-02T09:00:00Z\",\"addedBy\":\"admin@contoso.com\",\"role\":\"GlobalReader\"}"
+ "]}",
json);
Assert.DoesNotContain("partitionKey", json);
Assert.DoesNotContain("rowKey", json);
Assert.DoesNotContain("eTag", json);
Assert.DoesNotContain("timestamp", json);
}
// ---- AuthFunction: AddGlobalAdmin ----------------------------------------------------
[Fact]
public void AddGlobalAdminResponse_matches_the_created_entity_shape()
public void AddGlobalAdminResponse_pins_the_created_row_shape()
{
var newAdmin = new GlobalAdminEntity
var newAdmin = new GlobalAdminRow
{
RowKey = "new.admin@contoso.com",
Upn = "new.admin@contoso.com",
IsEnabled = true,
AddedDate = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc),
@@ -88,9 +279,9 @@ public class InfrastructureWireParityTests
Role = "GlobalAdmin",
};
AssertParity(
new { admin = newAdmin },
new AddGlobalAdminResponse { Admin = newAdmin });
Assert.Equal(
"{\"admin\":{\"upn\":\"new.admin@contoso.com\",\"isEnabled\":true,\"addedDate\":\"2026-08-30T12:00:00Z\",\"addedBy\":\"admin@contoso.com\",\"role\":\"GlobalAdmin\"}}",
TestWire.Serialize(new AddGlobalAdminResponse { Admin = newAdmin }));
}
// ---- HealthCheckFunction: HealthCheck ------------------------------------------------
@@ -560,8 +560,9 @@ public class MetricsWireParityTests
public void MetricsSummaryResponse_matches_the_tally_shape()
{
var days = 30;
// GetMetricsSummaryAsync returns List<object> of repository-built per-tenant rows
// (shape mirrored from TableStorageService.GetMetricsSummaryAsync).
// Left: the exact anonymous per-tenant row GetMetricsSummaryAsync built before the
// MetricsSummaryTenantItem substitution — proves the DTO item is wire-identical
// (incl. the windowDays duplicate on every item).
var summary = new List<object>
{
new
@@ -586,7 +587,24 @@ public class MetricsWireParityTests
new MetricsSummaryResponse
{
Success = true,
Summary = summary,
Summary = new List<MetricsSummaryTenantItem>
{
new MetricsSummaryTenantItem
{
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
TotalSessions = 10,
Succeeded = 6,
Failed = 2,
InProgress = 1,
Pending = 0,
Stalled = 0,
AwaitingUser = 0,
Incomplete = 1,
Other = 0,
FailureRate = 25.0,
WindowDays = days,
},
},
WindowDays = days,
});
}
@@ -6,6 +6,7 @@ using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
@@ -79,7 +80,7 @@ public class PreviewNotificationEmailWriteGateTests
var admins = new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
{ CallBase = false };
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminEntity>
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminRow>
{
new() { TenantId = TenantId, Upn = "former@contoso.com", IsEnabled = false, Role = Constants.TenantRoles.Admin },
});
@@ -93,9 +94,9 @@ public class PreviewNotificationEmailWriteGateTests
var admins = new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
{ CallBase = false };
var members = new List<TenantAdminEntity>();
var members = new List<TenantAdminRow>();
if (enabledMember)
members.Add(new TenantAdminEntity { TenantId = TenantId, Upn = "admin@contoso.com", IsEnabled = true, Role = Constants.TenantRoles.Admin });
members.Add(new TenantAdminRow { TenantId = TenantId, Upn = "admin@contoso.com", IsEnabled = true, Role = Constants.TenantRoles.Admin });
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(members);
return admins;
}
@@ -302,24 +302,29 @@ public class RulesWireParityTests
}
// ---- GetPreviewWhitelist -------------------------------------------------------------
// DELIBERATE wire change (2026-08-31 entity-hygiene pass): rows carried the tenant id only
// in partitionKey plus garbage entity defaults; the contract is now { tenantId } per row.
[Fact]
public void GetPreviewWhitelistResponse_matches_the_approved_tenant_listing_shape()
public void GetPreviewWhitelistResponse_pins_the_approved_tenant_listing_shape()
{
var approved = new List<PreviewWhitelistEntity>
var approved = new List<PreviewWhitelistTenantEntry>
{
new PreviewWhitelistEntity
{
PartitionKey = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
RowKey = "approved",
ApprovedAt = new DateTime(2026, 8, 30, 9, 30, 0, DateTimeKind.Utc),
ApprovedBy = "admin@contoso.com",
},
new PreviewWhitelistTenantEntry { TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d" },
new PreviewWhitelistTenantEntry { TenantId = "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" },
};
AssertParity(
new { tenants = approved },
new GetPreviewWhitelistResponse { Tenants = approved });
var json = TestWire.Serialize(new GetPreviewWhitelistResponse { Tenants = approved });
Assert.Equal(
"{\"tenants\":["
+ "{\"tenantId\":\"6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d\"},"
+ "{\"tenantId\":\"7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e\"}"
+ "]}",
json);
Assert.DoesNotContain("partitionKey", json);
Assert.DoesNotContain("rowKey", json);
Assert.DoesNotContain("eTag", json);
Assert.DoesNotContain("approvedAt", json);
}
// ---- GetPreviewNotificationEmail -----------------------------------------------------
@@ -176,6 +176,101 @@ public class TypedResponseGuardTests
+ string.Join("\n ", nonFlat));
}
// ── Object-slot ratchet (closed 2026-08-31, typisierung follow-up) ──────────────────
// Regexes A-E police the FUNCTION side; this one polices the DTOs themselves: a Shared
// wire type carrying an `object` (or collection-of-object) property hides that slot's
// shape from the manifest exactly like an anonymous body would. `[ProjectedItems]` slots
// (fields=-projections) and dictionary VALUES (heterogeneous bags by design, e.g.
// HealthCheck.Details) are exempt.
/// <summary>
/// The deliberately-open object slots. Every other object-typed property on a reachable
/// wire type is a regression: give the slot a concrete Shared type.
/// </summary>
private static readonly HashSet<string> ObjectSlotBaseline = new(StringComparer.Ordinal)
{
// Matched: heterogeneous evidence dictionary; not matched: the evaluator's reason string.
"AutopilotMonitor.Shared.Models.RuleDryRunCondition.Evidence",
};
[Fact]
public void No_reachable_wire_type_property_is_object_typed_outside_the_baseline()
{
var shared = typeof(IApiResponse).Assembly;
var roots = shared.GetTypes().Where(t =>
!t.IsInterface && !t.IsAbstract &&
(typeof(IApiResponse).IsAssignableFrom(t) ||
t.GetCustomAttributes(typeof(WireContractAttribute), inherit: false).Length > 0));
var seen = new HashSet<Type>();
var queue = new Queue<Type>(roots);
var offenders = new List<string>();
var baselineHits = new HashSet<string>(StringComparer.Ordinal);
while (queue.Count > 0)
{
var type = queue.Dequeue();
if (!seen.Add(type))
continue;
foreach (var prop in type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance))
{
if (prop.GetCustomAttributes(typeof(ProjectedItemsAttribute), inherit: false).Length > 0)
continue; // fields=-projection slot: IReadOnlyList<object> by design.
var slotType = prop.PropertyType;
var elementType = EnumerableElementType(slotType);
var effective = elementType ?? slotType;
if (effective == typeof(object))
{
var slot = $"{type.FullName}.{prop.Name}";
if (ObjectSlotBaseline.Contains(slot))
baselineHits.Add(slot);
else
offenders.Add(slot);
}
else if (effective.Assembly == shared && effective.IsClass && effective != typeof(string))
{
queue.Enqueue(effective);
}
}
}
Assert.True(offenders.Count == 0,
"object-typed properties on reachable wire types (shape invisible to the manifest) — "
+ "give the slot a concrete Shared type or document it in ObjectSlotBaseline:\n "
+ string.Join("\n ", offenders.OrderBy(s => s, StringComparer.Ordinal)));
// Ratchet down: a baseline entry whose slot no longer exists (or is no longer object)
// must be removed, so the documented-debt list stays truthful.
var stale = ObjectSlotBaseline.Except(baselineHits).ToList();
Assert.True(stale.Count == 0,
"ObjectSlotBaseline entries that no longer match an object-typed slot — remove them:\n "
+ string.Join("\n ", stale));
}
/// <summary>
/// Element type of a generic IEnumerable&lt;T&gt; slot (arrays included), EXCEPT dictionaries —
/// dictionary values are heterogeneous bags by design and stay exempt. Null for scalars.
/// </summary>
private static Type? EnumerableElementType(Type type)
{
if (type == typeof(string))
return null;
if (type.IsArray)
return type.GetElementType();
var enumerable = new[] { type }.Concat(type.GetInterfaces())
.FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>));
if (enumerable == null)
return null;
var element = enumerable.GetGenericArguments()[0];
// KeyValuePair element ⇒ the slot is a dictionary; values are exempt by design.
if (element.IsGenericType && element.GetGenericTypeDefinition() == typeof(KeyValuePair<,>))
return null;
return element;
}
/// <summary>
/// Error shape = first property of the anonymous object is <c>error</c> or <c>message</c>
/// (assigned or C# shorthand), or the literal <c>success = false</c>. Everything else —
@@ -98,7 +98,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<int> DeleteUserPresenceOlderThanAsync(DateTime cutoffUtc)
=> _storage.DeleteUserPresenceOlderThanAsync(cutoffUtc);
public Task<List<object>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
public Task<List<MetricsSummaryTenantItem>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
=> _storage.GetMetricsSummaryAsync(tenantId, days);
public Task IncrementRuleStatAsync(string date, string tenantId, string ruleId, string ruleType,
@@ -83,7 +83,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
var result = await _service.RunAsync(table, dryRun, maxRows, continuation);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new MaintenanceJobRunResponse
await response.WriteAsJsonAsync(new BackfillJobRunResponse
{
Success = true,
Result = result,
@@ -101,7 +101,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new MaintenanceJobRunResponse
await response.WriteAsJsonAsync(new ReclassifyJobRunResponse
{
Success = true,
Result = result,
@@ -275,7 +275,7 @@ public class AuthFunction
return unresolved;
}
GlobalAdminEntity newAdmin;
GlobalAdminRow newAdmin;
try
{
newAdmin = await _globalAdminService.AddGlobalAdminAsync(body.Upn, currentUpn!, identity.Value.TenantId, identity.Value.ObjectId);
@@ -588,30 +588,30 @@ public class AuthFunction
string? role = needsAutoAdmin ? Constants.TenantRoles.Admin : memberRole?.Role;
bool canManageBootstrapTokens = needsAutoAdmin || (memberRole?.CanManageBootstrapTokens ?? false);
return AuthDecisionResult.Success(new
return AuthDecisionResult.Success(new AuthMeResponse
{
tenantId,
upn,
displayName,
objectId,
isGlobalAdmin,
isGlobalReader,
isTenantAdmin,
TenantId = tenantId,
Upn = upn,
DisplayName = displayName,
ObjectId = objectId,
IsGlobalAdmin = isGlobalAdmin,
IsGlobalReader = isGlobalReader,
IsTenantAdmin = isTenantAdmin,
// Delegated ("MSP") scope: the OTHER tenants this caller may manage (read-only this phase) and a
// convenience flag. The web app uses these for fleet/switcher UI, bounded to this set.
isDelegated,
delegatedTenantIds = managedTenantIds,
role,
canManageBootstrapTokens,
hasMcpAccess = mcpCheck.IsAllowed,
IsDelegated = isDelegated,
DelegatedTenantIds = managedTenantIds,
Role = role,
CanManageBootstrapTokens = canManageBootstrapTokens,
HasMcpAccess = mcpCheck.IsAllowed,
// Dual app-reg window: which app registration this tenant is homed on. The web app
// stores this per browser (localStorage) so the NEXT login uses the right app —
// the deferred, no-roundtrip learning mechanism of the parallel operation model.
homedApp,
HomedApp = homedApp,
// EFFECTIVE availability flags (drive sidebar/section visibility): bootstrap is
// included in Pro, Unrestricted Mode requires Pro + the GA on-request gate.
bootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow),
unrestrictedModeEnabled =
BootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow),
UnrestrictedModeEnabled =
FeatureEntitlementCatalog.Get(TenantEntitlementService.ResolveEdition(tenantConfig, DateTime.UtcNow)).UnrestrictedModeAvailable
&& tenantConfig.UnrestrictedModeEnabled
}, needsAutoAdmin);
@@ -203,38 +203,23 @@ public class McpUserFunction
var result = await _mcpUserService.IsAllowedAsync(upn, principal?.GetTenantId(), principal?.GetObjectId());
var response = req.CreateResponse(result.IsAllowed ? HttpStatusCode.OK : HttpStatusCode.Forbidden);
var payload = new Dictionary<string, object?>
// Only surface platform-role flags when the caller actually has one (null ⇒ key omitted).
// A normal tenant user gets neither field: the MCP access-guard reads `globalRole` to decide
// cross-tenant routing (and keeps reading `isGlobalAdmin === true` for back-compat /
// write-tier hints), and we avoid hinting to ordinary callers that a platform tier even
// exists. delegatedTenantIds bounds cross-tenant routing (/api/global/*?tenantId=<managed>)
// to exactly these tenants and is only emitted for a caller holding a delegated assignment.
await response.WriteAsJsonAsync(new CheckMcpAccessResponse
{
["allowed"] = result.IsAllowed,
["upn"] = result.Upn,
["accessGrant"] = result.AccessGrant,
["reason"] = result.Reason,
};
// Only surface platform-role flags when the caller actually has one. A normal tenant user gets
// neither field: the MCP access-guard reads `globalRole` to decide cross-tenant routing (and
// keeps reading `isGlobalAdmin === true` for back-compat / write-tier hints), and we avoid
// hinting to ordinary callers that a platform tier even exists.
if (result.IsGlobalAdmin)
{
payload["isGlobalAdmin"] = true;
}
if (!string.IsNullOrEmpty(result.GlobalRole))
{
payload["globalRole"] = result.GlobalRole; // "GlobalAdmin" | "GlobalReader"
}
// Delegated (scoped-global / MSP) scope, when present. The MCP access-guard reads
// delegatedTenantIds to route the caller cross-tenant (/api/global/*?tenantId=<managed>) bounded to
// exactly these tenants, and to reject any tool call that does not name one of them. Only emitted
// for a caller that actually holds a delegated assignment — ordinary tenant users get neither field.
if (result.DelegatedTenantIds is { Count: > 0 })
{
payload["delegatedTenantIds"] = result.DelegatedTenantIds; // string[] (lowercase)
}
if (!string.IsNullOrEmpty(result.DelegatedRole))
{
payload["delegatedRole"] = result.DelegatedRole; // "DelegatedAdmin" | "DelegatedReader"
}
await response.WriteAsJsonAsync(payload);
Allowed = result.IsAllowed,
Upn = result.Upn,
AccessGrant = result.AccessGrant,
Reason = result.Reason,
IsGlobalAdmin = result.IsGlobalAdmin ? true : null,
GlobalRole = string.IsNullOrEmpty(result.GlobalRole) ? null : result.GlobalRole,
DelegatedTenantIds = result.DelegatedTenantIds is { Count: > 0 } ? result.DelegatedTenantIds : null,
DelegatedRole = string.IsNullOrEmpty(result.DelegatedRole) ? null : result.DelegatedRole,
});
return response;
}
}
@@ -1,6 +1,7 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure.Data.Tables;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -109,7 +110,7 @@ public class GlobalAdminService
/// <param name="homeTenantId">The Entra tenant the person signs in from (JWT tid).</param>
/// <param name="objectId">The person's Entra object id, or null to pin it on their first sign-in.</param>
/// <exception cref="IdentityBindingConflictException">The UPN is already bound to a different identity.</exception>
public async Task<GlobalAdminEntity> AddGlobalAdminAsync(string upn, string addedBy, string homeTenantId, string? objectId)
public async Task<GlobalAdminRow> AddGlobalAdminAsync(string upn, string addedBy, string homeTenantId, string? objectId)
{
upn = upn.ToLowerInvariant();
addedBy = addedBy.ToLowerInvariant();
@@ -120,10 +121,8 @@ public class GlobalAdminService
// Invalidate cache
_cache.Remove($"global-role:{upn}");
return new GlobalAdminEntity
return new GlobalAdminRow
{
PartitionKey = "GlobalAdmins",
RowKey = upn,
Upn = upn,
IsEnabled = true,
AddedDate = DateTime.UtcNow,
@@ -161,14 +160,12 @@ public class GlobalAdminService
/// <summary>
/// Gets all Global Admins
/// </summary>
public async Task<List<GlobalAdminEntity>> GetAllGlobalAdminsAsync()
public async Task<List<GlobalAdminRow>> GetAllGlobalAdminsAsync()
{
var entries = await _adminRepo.GetAllGlobalAdminsAsync();
return entries.Select(e => new GlobalAdminEntity
return entries.Select(e => new GlobalAdminRow
{
PartitionKey = "GlobalAdmins",
RowKey = e.Upn,
Upn = e.Upn,
IsEnabled = e.IsEnabled,
AddedDate = e.AddedAt,
@@ -1,4 +1,5 @@
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging;
@@ -25,18 +26,8 @@ public class GlobalNotificationEntity : ITableEntity
public bool Dismissed { get; set; }
}
/// <summary>
/// DTO returned by the API for global notifications.
/// </summary>
public class GlobalNotificationDto
{
public string Id { get; set; } = string.Empty;
public string Type { get; set; } = "info";
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string? Href { get; set; }
public DateTime CreatedAt { get; set; }
}
// GlobalNotificationDto is wire contract and lives in AutopilotMonitor.Shared.Models
// (NotificationsApiModels.cs).
/// <summary>
/// Service for managing persistent Global Admin in-app notifications.
@@ -1,5 +1,6 @@
using AutopilotMonitor.Functions.Services.Monitoring;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
@@ -562,7 +563,9 @@ public class HealthCheckService
}
/// <summary>
/// Result of a health check operation
/// Result of a health check operation. Not itself on the wire — HealthCheckFunction decomposes
/// it into the typed response envelopes; the per-check item (HealthCheck) is wire contract and
/// lives in AutopilotMonitor.Shared.Models (InfrastructureApiModels.cs).
/// </summary>
public class HealthCheckResult
{
@@ -570,15 +573,3 @@ public class HealthCheckResult
public string OverallStatus { get; set; } = "unknown";
public List<HealthCheck> Checks { get; set; } = new();
}
/// <summary>
/// Individual health check result
/// </summary>
public class HealthCheck
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Status { get; set; } = "unknown";
public string Message { get; set; } = string.Empty;
public Dictionary<string, object>? Details { get; set; }
}
@@ -50,32 +50,8 @@ namespace AutopilotMonitor.Functions.Services
_configService = configService;
}
public sealed class ReclassificationSample
{
public string TenantId { get; set; } = string.Empty;
public string SessionId { get; set; } = string.Empty;
public string OldStatus { get; set; } = string.Empty;
public string NewStatus { get; set; } = string.Empty;
public string Reason { get; set; } = string.Empty;
}
public sealed class ReclassificationResult
{
public string Mode { get; set; } = string.Empty;
public bool DryRun { get; set; }
public int TenantsExamined { get; set; }
public int SessionsExamined { get; set; }
public int WouldChange { get; set; }
public int Changed { get; set; }
public int ToSucceeded { get; set; }
public int ToIncomplete { get; set; }
public int KeptFailed { get; set; }
public int Skipped { get; set; }
public int Errors { get; set; }
/// <summary>True when the maxSessions cap stopped the run before the backlog was exhausted — re-run to continue.</summary>
public bool CapReached { get; set; }
public List<ReclassificationSample> Samples { get; } = new();
}
// ReclassificationResult/ReclassificationSample are wire contract and live in
// AutopilotMonitor.Shared.Models (AdminApiModels.cs).
public async Task<ReclassificationResult> ReclassifyLegacyTimeoutsAsync(
string? tenantIdScope, bool dryRun, int maxSessions, string triggeredBy)
@@ -16,24 +16,7 @@ using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Result of a maintenance operation
/// </summary>
public class MaintenanceResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public string TriggeredBy { get; set; } = string.Empty;
public DateTime TriggeredAt { get; set; }
public int DurationMs { get; set; }
public bool StalledSessionsChecked { get; set; }
public bool MetricsAggregated { get; set; }
public string? AggregatedDate { get; set; }
public bool DataCleanupExecuted { get; set; }
public bool PlatformStatsRecomputed { get; set; }
public int DevicesBlockedForExcessiveData { get; set; }
public int ContactEmailsBackfilled { get; set; }
}
// MaintenanceResult is wire contract and lives in AutopilotMonitor.Shared.Models (AdminApiModels.cs).
/// <summary>
/// Dedicated service for maintenance tasks:
@@ -3,6 +3,7 @@ using Azure.Data.Tables;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
@@ -34,26 +35,8 @@ namespace AutopilotMonitor.Functions.Services
_logger = logger;
}
public class BackfillResult
{
public string Table { get; set; } = string.Empty;
public bool DryRun { get; set; }
public int RowsExamined { get; set; }
public int WouldWrite { get; set; }
public int Written { get; set; }
public int SkippedAlreadySet { get; set; }
public int SkippedUndecodable { get; set; }
public int Errors { get; set; }
public string? NextContinuation { get; set; }
public List<BackfillSample> Samples { get; set; } = new();
}
public class BackfillSample
{
public string PartitionKey { get; set; } = string.Empty;
public string RowKey { get; set; } = string.Empty;
public DateTime DecodedUtc { get; set; }
}
// BackfillResult/BackfillSample are wire contract and live in
// AutopilotMonitor.Shared.Models (AdminApiModels.cs).
internal enum RowDecision { AlreadySet, Write, Undecodable }
@@ -1,5 +1,6 @@
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -124,18 +125,12 @@ public class PreviewWhitelistService
/// <summary>
/// Returns all approved tenants (for Global Admin overview).
/// Returns PreviewWhitelistEntity list for backward compatibility with existing API consumers.
/// </summary>
public async Task<List<PreviewWhitelistEntity>> GetAllApprovedAsync()
public async Task<List<PreviewWhitelistTenantEntry>> GetAllApprovedAsync()
{
var tenantIds = await _configRepo.GetPreviewWhitelistAsync();
// Convert string list back to entity list for backward compatibility
return tenantIds.Select(id => new PreviewWhitelistEntity
{
PartitionKey = id,
RowKey = "approved"
}).ToList();
return tenantIds.Select(id => new PreviewWhitelistTenantEntry { TenantId = id }).ToList();
}
/// <summary>
@@ -156,83 +156,6 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>Verdict strings for <see cref="RuleDryRun.Verdict"/>. Stable API contract.</summary>
public static class RuleDryRunVerdict
{
public const string Fired = "fired";
public const string SkippedByPrecondition = "skipped_by_precondition";
public const string RequiredConditionNotMet = "required_condition_not_met";
public const string NoConditionsMatched = "no_conditions_matched";
public const string BelowConfidenceThreshold = "below_confidence_threshold";
public const string NoEvents = "no_events";
}
/// <summary>Full diagnostic trace of one dry-run evaluation. Serialized camelCase to clients.</summary>
public sealed class RuleDryRun
{
public string Verdict { get; set; } = string.Empty;
/// <summary>Number of events in the session the rule was evaluated against.</summary>
public int EventCount { get; set; }
public List<RuleDryRunPrecondition> Preconditions { get; } = new();
public List<RuleDryRunCondition> Conditions { get; } = new();
/// <summary>Empty unless all required conditions were met (mirrors the production path,
/// which never reaches factor evaluation otherwise).</summary>
public List<RuleDryRunFactor> ConfidenceFactors { get; } = new();
public int BaseConfidence { get; set; }
/// <summary>base + matched factor weights, capped at 100. Null when the evaluation ended
/// before the confidence stage (precondition skip / required miss / nothing matched).</summary>
public int? FinalConfidence { get; set; }
public int ConfidenceThreshold { get; set; }
/// <summary>True only for verdict "fired" AND the rule's effective MarkSessionAsFailed flag.
/// The dry-run itself never touches the session.</summary>
public bool WouldMarkSessionAsFailed { get; set; }
/// <summary>The evidence map exactly as the production path would persist it on a
/// RuleResult — keys are condition signals (plus factor_* markers). Clients use it to
/// preview {{token}} interpolation of explanation/remediation.</summary>
public Dictionary<string, object>? MatchedConditions { get; set; }
}
public sealed class RuleDryRunPrecondition
{
public string Source { get; set; } = string.Empty;
public string EventType { get; set; } = string.Empty;
public string? DataField { get; set; }
public string? Operator { get; set; }
public string? Value { get; set; }
public bool Passed { get; set; }
}
public sealed class RuleDryRunCondition
{
public string Signal { get; set; } = string.Empty;
public string Source { get; set; } = string.Empty;
public string? EventType { get; set; }
public bool Required { get; set; }
public bool Matched { get; set; }
/// <summary>Matched: the evidence dictionary (eventId, timestamp, field, value, …).
/// Not matched: the evaluator's reason string (e.g. "no matching events").</summary>
public object? Evidence { get; set; }
/// <summary>How many session events have this condition's eventType at all — the first
/// thing an author checks when a condition unexpectedly doesn't match. Null when the
/// condition has no eventType.</summary>
public int? MatchingEventCount { get; set; }
}
public sealed class RuleDryRunFactor
{
public string Signal { get; set; } = string.Empty;
public string Condition { get; set; } = string.Empty;
public int Weight { get; set; }
public bool Matched { get; set; }
}
// RuleDryRun, RuleDryRunVerdict and the per-stage trace classes are wire contract and live in
// AutopilotMonitor.Shared.Models (RulesApiModels.cs).
}
@@ -1457,7 +1457,7 @@ namespace AutopilotMonitor.Functions.Services
internal static string? BuildDeviceSnapshotTenantFilter(string? tenantId)
=> string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'";
public async Task<List<object>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
public async Task<List<MetricsSummaryTenantItem>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
{
// Clamp to a sane range so callers can't accidentally trigger an unbounded scan.
if (days < 1) days = 1;
@@ -1489,22 +1489,22 @@ namespace AutopilotMonitor.Functions.Services
// Incomplete (unknown, non-failure) is deliberately excluded from the denominator, and
// non-terminal states (InProgress/AwaitingUser/Pending/Stalled) never belonged in it.
var terminal = b.Succeeded + b.Failed;
return (object)new
return new MetricsSummaryTenantItem
{
tenantId = kvp.Key,
totalSessions = b.Total,
succeeded = b.Succeeded,
failed = b.Failed,
inProgress = b.InProgress,
pending = b.Pending,
stalled = b.Stalled,
awaitingUser = b.AwaitingUser,
incomplete = b.Incomplete,
other = b.Other,
failureRate = terminal > 0
TenantId = kvp.Key,
TotalSessions = b.Total,
Succeeded = b.Succeeded,
Failed = b.Failed,
InProgress = b.InProgress,
Pending = b.Pending,
Stalled = b.Stalled,
AwaitingUser = b.AwaitingUser,
Incomplete = b.Incomplete,
Other = b.Other,
FailureRate = terminal > 0
? Math.Round((double)b.Failed / terminal * 100, 1)
: 0.0,
windowDays = days
WindowDays = days
};
}).ToList();
}
@@ -1,6 +1,7 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure.Data.Tables;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -83,7 +84,7 @@ public class TenantAdminsService
/// <param name="tenantId">Tenant ID</param>
/// <param name="upn">User Principal Name</param>
/// <param name="addedBy">UPN of the admin who is adding this user</param>
public virtual async Task<TenantAdminEntity> AddTenantAdminAsync(string tenantId, string upn, string addedBy)
public virtual async Task<TenantAdminRow> AddTenantAdminAsync(string tenantId, string upn, string addedBy)
{
tenantId = tenantId.ToLowerInvariant();
upn = upn.ToLowerInvariant();
@@ -95,10 +96,8 @@ public class TenantAdminsService
_logger.LogInformation($"Added Tenant Admin: {upn} to tenant {tenantId} by {addedBy}");
return new TenantAdminEntity
return new TenantAdminRow
{
PartitionKey = tenantId,
RowKey = upn,
TenantId = tenantId,
Upn = upn,
IsEnabled = true,
@@ -155,7 +154,7 @@ public class TenantAdminsService
/// <summary>
/// Gets all Tenant Admins for a specific tenant
/// </summary>
public virtual async Task<List<TenantAdminEntity>> GetTenantAdminsAsync(string tenantId)
public virtual async Task<List<TenantAdminRow>> GetTenantAdminsAsync(string tenantId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
@@ -165,10 +164,8 @@ public class TenantAdminsService
_logger.LogInformation($"Retrieved {members.Count} Tenant Admins for tenant {tenantId}");
return members.Select(m => new TenantAdminEntity
return members.Select(m => new TenantAdminRow
{
PartitionKey = m.TenantId,
RowKey = m.Upn,
TenantId = m.TenantId,
Upn = m.Upn,
IsEnabled = m.IsEnabled,
@@ -255,7 +252,7 @@ public class TenantAdminsService
/// <summary>
/// Adds a tenant member with a specific role.
/// </summary>
public async Task<TenantAdminEntity> AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false)
public async Task<TenantAdminRow> AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false)
{
tenantId = tenantId.ToLowerInvariant();
upn = upn.ToLowerInvariant();
@@ -267,10 +264,8 @@ public class TenantAdminsService
_logger.LogInformation("Added tenant member: {Upn} with role {Role} to tenant {TenantId} by {AddedBy}", upn, role, tenantId, addedBy);
return new TenantAdminEntity
return new TenantAdminRow
{
PartitionKey = tenantId,
RowKey = upn,
TenantId = tenantId,
Upn = upn,
IsEnabled = true,
@@ -57,20 +57,8 @@ namespace AutopilotMonitor.Functions.Services
=> new(false, failure, error, backupId, Array.Empty<string>(), null, drift);
}
/// <summary>
/// One entry of the tenant-config field schema (see TenantConfigPatchService.BuildFieldsSchema).
/// Serialized camelCase to the API. Nulls/false flags are serialized as-is — the payload is
/// tiny and consumed by a model, which reads explicit values better than absent keys.
/// </summary>
public sealed record TenantConfigFieldSchema(
string Name,
string Type,
string? Format,
bool Nullable,
bool Writable,
string? Reason,
bool GaOnly,
bool RevertProtected);
// TenantConfigFieldSchema is wire contract and lives in AutopilotMonitor.Shared.Models
// (ConfigApiModels.cs); the schema builder (BuildFieldsSchema) stays here.
/// <summary>
/// Transactional field-level writes against a tenant's configuration row:
@@ -498,14 +486,14 @@ namespace AutopilotMonitor.Functions.Services
var writable = !BaseDeniedFields.Contains(prop.Name);
fields.Add(new TenantConfigFieldSchema(
Name: char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1),
Type: type,
Format: format,
Nullable: nullable,
Writable: writable,
Reason: writable ? null : DeniedReason(prop.Name),
GaOnly: writable && GaOnlyFields.Contains(prop.Name),
RevertProtected: RevertProtectedFields.Contains(prop.Name)));
name: char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1),
type: type,
format: format,
nullable: nullable,
writable: writable,
reason: writable ? null : DeniedReason(prop.Name),
gaOnly: writable && GaOnlyFields.Contains(prop.Name),
revertProtected: RevertProtectedFields.Contains(prop.Name)));
}
return fields.OrderBy(f => f.Name, StringComparer.Ordinal).ToList();
});
@@ -1,4 +1,5 @@
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
@@ -12,6 +12,7 @@ import crypto from 'node:crypto';
import { extractTokenClaims, isTokenExpired } from './auth.js';
import { runWithCaller } from './client.js';
import { API_BASE_URL, getPublicBaseUrl, parsePositiveInt } from './config.js';
import type { CheckMcpAccessResponse } from './generated/wire-types.generated.js';
const BASE_URL = API_BASE_URL;
@@ -187,19 +188,9 @@ async function checkAccess(upn: string, token: string, clientIp: string): Promis
return { allowed: false, reason: `Backend returned ${res.status} with empty body`, isGlobalAdmin: false, isGlobalReader: false, infraError: true };
}
const data = JSON.parse(text) as {
allowed: boolean;
reason?: string;
accessGrant?: string;
isGlobalAdmin?: boolean;
// Platform role: "GlobalAdmin" | "GlobalReader" (absent → no platform role). The read-only
// GlobalReader gets the same cross-tenant routing as GA because this server is read-only.
globalRole?: string;
// Delegated (scoped-global / MSP) scope: the managed tenant IDs (lowercase) and strongest role.
// Present only for a caller that holds a delegated assignment.
delegatedTenantIds?: unknown;
delegatedRole?: string;
};
// Wire type is generated from the backend DTO (CheckMcpAccessResponse). The read-only
// GlobalReader gets the same cross-tenant routing as GA because this server is read-only.
const data = JSON.parse(text) as CheckMcpAccessResponse;
// Normalize defensively: accept only a non-empty array of strings, lowercased. Anything else
// (missing, wrong type, empty) collapses to undefined → the caller is treated as non-delegated.
const delegatedTenantIds = Array.isArray(data.delegatedTenantIds)
@@ -17,9 +17,9 @@ export interface ActiveUserItem {
secondsAgo: number;
}
/** Response of POST auth/global-admins (201): the created GlobalAdminEntity (backend-project type, serialized by runtime type). */
/** Response of POST auth/global-admins (201): the created row. */
export interface AddGlobalAdminResponse {
admin: unknown;
admin: GlobalAdminRow;
}
/** Response of POST vulnerability/ignored-software: how many entries were added. */
@@ -540,6 +540,29 @@ export interface AuditLogListResponse {
nextLink?: string;
}
/** Success body of GET auth/me: the caller's resolved identity, roles and effective entitlement flags. Blocked outcomes (TenantSuspended / PendingActivation) are error shapes and stay anonymous by design. */
export interface AuthMeResponse {
tenantId: string;
upn: string;
displayName: string;
objectId: string;
isGlobalAdmin: boolean;
isGlobalReader: boolean;
isTenantAdmin: boolean;
/** True when the caller holds delegated ("MSP") assignments to other tenants. */
isDelegated: boolean;
/** The OTHER tenants this caller may manage; empty for non-delegated callers. */
delegatedTenantIds: string[];
/** Tenant role (Admin / Operator / Viewer); the key is omitted for a roleless caller. */
role?: string;
canManageBootstrapTokens: boolean;
hasMcpAccess: boolean;
/** "primary" or "legacy" — which app registration this tenant is homed on. */
homedApp: string;
bootstrapTokenEnabled: boolean;
unrestrictedModeEnabled: boolean;
}
/** Response of POST vulnerability/cpe-mapping/auto-resolve: per-item outcomes plus totals. */
export interface AutoResolveCpeMappingResponse {
resolved: AutoResolveResultItem[];
@@ -588,6 +611,35 @@ export interface AutopilotConsentUrlResponse {
willAutoFlipHoming: boolean;
}
/** Response of POST maintenance/backfill-occurred-utc: the backfill run report plus trigger attribution. Key layout is identical to ReclassifyJobRunResponse — the two one-shot job triggers share one wire shape, split into two classes only for the typed slot. */
export interface BackfillJobRunResponse {
success: boolean;
result: BackfillResult;
triggeredBy: string;
triggeredAt: string;
}
/** Run report of the one-shot OccurredUtc backfill (POST maintenance/backfill-occurred-utc). */
export interface BackfillResult {
table: string;
dryRun: boolean;
rowsExamined: number;
wouldWrite: number;
written: number;
skippedAlreadySet: number;
skippedUndecodable: number;
errors: number;
nextContinuation?: string;
samples: BackfillSample[];
}
/** One sampled row of a backfill run. PartitionKey/RowKey are PAYLOAD here — they identify the sampled storage row in a Global-Admin-only run report, not ITableEntity metadata. */
export interface BackfillSample {
partitionKey: string;
rowKey: string;
decodedUtc: string;
}
/** Response of POST devices/block: block/kill acknowledgement. */
export interface BlockDeviceResponse {
success: boolean;
@@ -648,6 +700,22 @@ export interface BlockingAppInterval {
seconds: number;
}
/** Body of GET auth/mcp (200 when allowed, 403 when denied — same shape on both). The four platform/delegated keys are emitted ONLY when the caller actually holds the tier (null ⇒ key omitted): ordinary tenant users must not learn a platform tier exists, and IsGlobalAdmin is only ever emitted as literal true. */
export interface CheckMcpAccessResponse {
allowed: boolean;
upn: string;
accessGrant: string;
reason: string;
/** Back-compat / write-tier hint for the MCP access-guard; true or omitted, never false. */
isGlobalAdmin?: boolean;
/** "GlobalAdmin" | "GlobalReader"; omitted without a platform role. */
globalRole?: string;
/** Managed tenant ids (lowercase) of a delegated (MSP) caller; omitted otherwise. */
delegatedTenantIds?: string[];
/** "DelegatedAdmin" | "DelegatedReader"; omitted without delegated scope. */
delegatedRole?: string;
}
/** A factor that increases confidence when matched */
export interface ConfidenceFactor {
/** Descriptive name for this factor */
@@ -851,12 +919,12 @@ export interface DeploymentTypeMetrics {
whiteGlovePercentage: number;
}
/** Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Items are HealthCheck objects (backend-project type, serialized by runtime type); non-GA callers get a filtered list. */
/** Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Non-GA callers get a filtered check list. */
export interface DetailedHealthCheckResponse {
service: string;
timestamp: string;
overallStatus: string;
checks: unknown[];
checks: HealthCheck[];
version: string;
commitHash: string;
buildUtc: string;
@@ -1062,8 +1130,8 @@ export interface DistressReportListResponse {
export interface DryRunAnalyzeRuleResponse {
success: boolean;
sessionId: string;
/** The RuleDryRun trace (AutopilotMonitor.Functions.Services) — typed object here only because that class lives in the Functions assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged. */
result: unknown;
/** The full diagnostic trace of the evaluation. */
result: RuleDryRun;
}
export interface EfficiencyOffender {
@@ -1393,9 +1461,9 @@ export interface GetDeviceHistoryResponse {
attemptNumber?: number;
}
/** Response of GET auth/global-admins: every Global Admin/Reader row. Items are GlobalAdminEntity table entities (backend-project type, serialized by runtime type — includes the ITableEntity keys, exactly as the anonymous site did). */
/** Response of GET auth/global-admins: every Global Admin/Reader row. */
export interface GetGlobalAdminsResponse {
admins: unknown[];
admins: GlobalAdminRow[];
}
/** Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server paginates over the summaries key — its name is wire-critical. */
@@ -1485,8 +1553,7 @@ export interface GetPreviewNotificationEmailResponse {
/** Response of GET preview/whitelist: every approved tenant (Global Admin only). */
export interface GetPreviewWhitelistResponse {
/** PreviewWhitelistEntity rows (AutopilotMonitor.Functions.DataAccess.TableStorage) — element-typed object here only because that entity lives in the Functions assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged. */
tenants: unknown[];
tenants: PreviewWhitelistTenantEntry[];
}
/** Session ids where a rule produced a result within the window (GetRuleHitSessions) — powers the dashboard's ?ruleId= deep link. */
@@ -1632,8 +1699,7 @@ export interface GetSessionTimeAttributionResponse {
export interface GetTenantConfigFieldsSchemaResponse {
count: number;
writableCount: number;
/** Schema entries (TenantConfigFieldSchema records, defined in the Functions project — typed as object here because Shared cannot reference it). */
fields: unknown[];
fields: TenantConfigFieldSchema[];
}
/** Per-tenant deletion-manifest tree envelope (GetTenantDeletionManifests). */
@@ -1694,6 +1760,17 @@ export interface GetVulnerabilitySyncStatusResponse {
epssLastRun?: EpssRefreshRunWire;
}
/** One Global Admin/Reader row on the wire. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them). */
export interface GlobalAdminRow {
/** User Principal Name (lowercase). */
upn: string;
isEnabled: boolean;
addedDate: string;
addedBy: string;
/** "GlobalAdmin" or "GlobalReader" (legacy empty rows are normalized to GlobalAdmin). */
role: string;
}
/** Global average benchmarks for geographic comparison. */
export interface GlobalAverages {
avgDurationMinutes: number;
@@ -1713,6 +1790,16 @@ export interface GlobalAverages {
totalDoBytesFromHttp: number;
}
/** One in-app notification (global pool or tenant-scoped; both endpoints share this DTO). A null Href key is omitted (WhenWritingNull). */
export interface GlobalNotificationDto {
id: string;
type: string;
title: string;
message: string;
href?: string;
createdAt: string;
}
/** One feature row of the graph-permissions status matrix: the feature identifier, the granted verdict (null while the snapshot is transient) and the Graph application permissions the feature requires. */
export interface GraphFeatureStatusItem {
name: string;
@@ -1753,6 +1840,15 @@ export interface HardwareRejectedResponse {
dataQualityNotice: string;
}
/** One health check result inside GET health/detailed / health/mcp. Details is a heterogeneous per-check bag by design (endpoint URLs only for Global Admins); a null Details key is omitted (WhenWritingNull). */
export interface HealthCheck {
name: string;
description: string;
status: string;
message: string;
details?: Record<string, unknown>;
}
/** Response of GET health: liveness probe plus the backend build identity. */
export interface HealthCheckResponse {
status: string;
@@ -2100,19 +2196,26 @@ export interface LogPatternTestResult {
readonly notes: string[];
}
/** Shared response of the one-shot maintenance job triggers (POST maintenance/backfill-occurred-utc, POST maintenance/reclassify-legacy): the service's run report plus trigger attribution. */
export interface MaintenanceJobRunResponse {
/** Run report of a manual maintenance run (POST maintenance/trigger). */
export interface MaintenanceResult {
success: boolean;
/** The service-specific run report (BackfillResult / ReclassificationResult — types owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */
result: unknown;
error?: string;
triggeredBy: string;
triggeredAt: string;
durationMs: number;
stalledSessionsChecked: boolean;
metricsAggregated: boolean;
aggregatedDate?: string;
dataCleanupExecuted: boolean;
platformStatsRecomputed: boolean;
devicesBlockedForExcessiveData: number;
contactEmailsBackfilled: number;
}
/** Response of GET health/mcp: the standalone MCP-server reachability probe. The check is a HealthCheck object (backend-project type, serialized by runtime type). */
/** Response of GET health/mcp: the standalone MCP-server reachability probe. */
export interface McpHealthCheckResponse {
timestamp: string;
check: unknown;
check: HealthCheck;
}
/** 429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly quota is exhausted (structurally a success shape: first key is quotaExceeded). */
@@ -2147,14 +2250,14 @@ export interface McpUserEntry {
usagePlan?: string;
}
/** Per-tenant session-status tally envelope shared by MetricsSummary and MetricsSummaryGlobal. Summary items are repository-built rows shaped like MetricsSummaryTenantItem. */
/** Per-tenant session-status tally envelope shared by MetricsSummary and MetricsSummaryGlobal. */
export interface MetricsSummaryResponse {
success: boolean;
summary: Partial<MetricsSummaryTenantItem>[];
summary: MetricsSummaryTenantItem[];
windowDays: number;
}
/** Wire shape of one per-tenant status tally in MetricsSummaryResponse (documentation type — the repository still builds the rows; every key is always present). */
/** One per-tenant status tally in MetricsSummaryResponse. WindowDays is repeated per item (envelope carries it too — historical wire shape, kept for parity). */
export interface MetricsSummaryTenantItem {
tenantId: string;
totalSessions: number;
@@ -2171,10 +2274,10 @@ export interface MetricsSummaryTenantItem {
windowDays: number;
}
/** Shared response of GET global/notifications and GET notifications: the active (non-dismissed) notifications visible to the caller, newest first. Items are GlobalNotificationDto objects (backend-project type, serialized by runtime type). */
/** Shared response of GET global/notifications and GET notifications: the active (non-dismissed) notifications visible to the caller, newest first. */
export interface NotificationListResponse {
success: boolean;
notifications: unknown[];
notifications: GlobalNotificationDto[];
}
/** Outcome of the last NVD stale-cache refresh walk on THIS instance (in-memory). */
@@ -2322,6 +2425,11 @@ export interface PlatformUsageMetrics {
windowDays: number;
}
/** One approved tenant on the wire. Deliberately NOT the storage entity: the pre-2026-08-31 wire carried synthetic PreviewWhitelistEntity rows whose only real datum was the tenant id in partitionKey (plus garbage defaults) — the contract is now just the id. */
export interface PreviewWhitelistTenantEntry {
tenantId: string;
}
/** Response of GET /api/progress/sessions/{sessionId}/events — the session's event stream after the serial knowledge proof passed. */
export interface ProgressGetSessionEventsResponse {
success: boolean;
@@ -2403,6 +2511,40 @@ export interface RebootSpan {
segmentKey: string;
}
/** Run report of the legacy-session reclassification job (POST maintenance/reclassify-legacy). */
export interface ReclassificationResult {
mode: string;
dryRun: boolean;
tenantsExamined: number;
sessionsExamined: number;
wouldChange: number;
changed: number;
toSucceeded: number;
toIncomplete: number;
keptFailed: number;
skipped: number;
errors: number;
/** True when the maxSessions cap stopped the run before the backlog was exhausted — re-run to continue. */
capReached: boolean;
readonly samples: ReclassificationSample[];
}
export interface ReclassificationSample {
tenantId: string;
sessionId: string;
oldStatus: string;
newStatus: string;
reason: string;
}
/** Response of POST maintenance/reclassify-legacy: the reclassification run report plus trigger attribution. See BackfillJobRunResponse for the shared key layout. */
export interface ReclassifyJobRunResponse {
success: boolean;
result: ReclassificationResult;
triggeredBy: string;
triggeredAt: string;
}
/** Structural health report for a session's persisted SignalLog + DecisionTransitions journal. Produced by GET /api/sessions/{id}/reducer-verification (Plan §M5, admin/ops endpoint, not tenant-exposed). Scope: this report covers structural invariants that can be checked without running the reducer — ordinal contiguity, cross-references, ReducerVersion drift, counts. A full engine replay with per-step diff would require polymorphic deserialisation of the DecisionSignal.Evidence payload and is a dedicated follow-up. */
export interface ReducerVerificationReport {
tenantId: string;
@@ -2530,6 +2672,53 @@ export interface RuleCondition {
eventAFilterValue: string;
}
/** Full diagnostic trace of one dry-run evaluation. Serialized camelCase to clients. */
export interface RuleDryRun {
verdict: string;
/** Number of events in the session the rule was evaluated against. */
eventCount: number;
readonly preconditions: RuleDryRunPrecondition[];
readonly conditions: RuleDryRunCondition[];
/** Empty unless all required conditions were met (mirrors the production path, which never reaches factor evaluation otherwise). */
readonly confidenceFactors: RuleDryRunFactor[];
baseConfidence: number;
/** base + matched factor weights, capped at 100. Null when the evaluation ended before the confidence stage (precondition skip / required miss / nothing matched). */
finalConfidence?: number;
confidenceThreshold: number;
/** True only for verdict "fired" AND the rule's effective MarkSessionAsFailed flag. The dry-run itself never touches the session. */
wouldMarkSessionAsFailed: boolean;
/** The evidence map exactly as the production path would persist it on a RuleResult — keys are condition signals (plus factor_* markers). Clients use it to preview {{token}} interpolation of explanation/remediation. Values are heterogeneous evidence objects by design. */
matchedConditions?: Record<string, unknown>;
}
export interface RuleDryRunCondition {
signal: string;
source: string;
eventType?: string;
required: boolean;
matched: boolean;
/** Matched: the evidence dictionary (eventId, timestamp, field, value, …). Not matched: the evaluator's reason string (e.g. "no matching events"). */
evidence?: unknown;
/** How many session events have this condition's eventType at all — the first thing an author checks when a condition unexpectedly doesn't match. Null when the condition has no eventType. */
matchingEventCount?: number;
}
export interface RuleDryRunFactor {
signal: string;
condition: string;
weight: number;
matched: boolean;
}
export interface RuleDryRunPrecondition {
source: string;
eventType: string;
dataField?: string;
operator?: string;
value?: string;
passed: boolean;
}
/** A device-fact gate evaluated before a rule's conditions. Pure boolean filter — does not contribute to evidence or confidence. When any precondition on a rule fails, the rule is silently skipped (no result emitted). Currently only event_data source is supported. */
export interface RulePrecondition {
/** Source of the fact. Currently only event_data. */
@@ -3223,8 +3412,22 @@ export interface TemplateVariable {
/** Response of POST tenants/{tenantId}/admins: the created tenant member. */
export interface TenantAdminCreatedResponse {
/** The stored TenantAdminEntity row (type owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */
admin: unknown;
admin: TenantAdminRow;
}
/** One tenant member (Admin / Operator / Viewer) on the wire — the item of the bare-array GET tenants/{tenantId}/admins and the created member of the POST. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them). */
export interface TenantAdminRow {
/** Tenant ID (lowercase). */
tenantId: string;
/** User Principal Name (lowercase). */
upn: string;
isEnabled: boolean;
addedDate: string;
addedBy: string;
/** Role: "Admin", "Operator", "Viewer". A null (legacy pre-role row) omits the key and means Admin. */
role?: string;
/** Whether this Operator can manage bootstrap tokens (only relevant for Operator). */
canManageBootstrapTokens: boolean;
}
/** One pre-write config snapshot in GET config/{tenantId}/backups — metadata only, the raw EntityJson (clear-text secrets) is never returned. */
@@ -3241,6 +3444,18 @@ export interface TenantConfigBackupItem {
diff?: Record<string, string>;
}
/** One entry of the tenant-config field schema (built by TenantConfigPatchService.BuildFieldsSchema). Serialized camelCase; null Format/Reason keys are omitted (WhenWritingNull) — the null and set cases are pinned by ConfigWireParityTests. */
export interface TenantConfigFieldSchema {
name: string;
type: string;
format?: string;
nullable: boolean;
writable: boolean;
reason?: string;
gaOnly: boolean;
revertProtected: boolean;
}
/** Success response of PATCH config/{tenantId}/fields and POST config/{tenantId}/revert (both flow through the same outcome writer): which fields changed, the masked diff, and the pre-write backup id. */
export interface TenantConfigPatchOutcomeResponse {
success: boolean;
@@ -3696,8 +3911,7 @@ export interface TriggerEpssSyncResponse {
export interface TriggerMaintenanceResponse {
success: boolean;
message: string;
/** The MaintenanceResult run report (type owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */
result: unknown;
result: MaintenanceResult;
triggeredBy: string;
triggeredAt: string;
}
@@ -13,6 +13,7 @@ import type {
GetGlobalMcpUsageResponse,
GetMcpUserUsageResponse,
GetMyMcpUsageResponse,
GetTenantConfigFieldsSchemaResponse,
GetUnmatchedSoftwareResponse,
PlatformAgentMetricsResponse,
QueryRawTableResponse,
@@ -1322,7 +1323,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
},
async (args) => withToolTelemetry('get_tenant_config_schema', args, async () => {
try {
const data = await apiFetch('/api/config/fields-schema');
const data = await apiFetch('/api/config/fields-schema') as GetTenantConfigFieldsSchemaResponse;
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) {
return toolError('get_tenant_config_schema', args, error);
@@ -127,11 +127,8 @@ export function registerRuleTools(server: McpServer, ga: boolean): void {
body: JSON.stringify({ sessionId: args.sessionId, rule: args.rule }),
}) as DryRunAnalyzeRuleResponse;
// `result` is `unknown` on the wire by design (the RuleDryRun trace lives in the
// Functions assembly) — read the two fields this tool needs via a local view.
const result = data.result as { verdict?: string; matchedConditions?: Record<string, unknown> } | undefined;
const rule = args.rule as Record<string, unknown>;
const mc = result?.matchedConditions;
const mc = data.result.matchedConditions;
// Preview the explanation/remediation exactly as the portal would render them
// for this session's evidence ({{token}} interpolation).
@@ -92,7 +92,7 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<int> DeleteUserPresenceOlderThanAsync(DateTime cutoffUtc);
// --- Metrics Summary (Agent API) ---
Task<List<object>> GetMetricsSummaryAsync(string? tenantId, int days = 30);
Task<List<MetricsSummaryTenantItem>> GetMetricsSummaryAsync(string? tenantId, int days = 30);
// --- Rule Stats ---
Task IncrementRuleStatAsync(string date, string tenantId, string ruleId, string ruleType,
@@ -101,20 +101,28 @@ namespace AutopilotMonitor.Shared.Models
}
/// <summary>
/// Shared response of the one-shot maintenance job triggers (POST maintenance/backfill-occurred-utc,
/// POST maintenance/reclassify-legacy): the service's run report plus trigger attribution.
/// Response of POST maintenance/backfill-occurred-utc: the backfill run report plus trigger
/// attribution. Key layout is identical to <see cref="ReclassifyJobRunResponse"/> — the two
/// one-shot job triggers share one wire shape, split into two classes only for the typed slot.
/// </summary>
// Declaration order == wire order.
public class MaintenanceJobRunResponse : IApiResponse
public class BackfillJobRunResponse : IApiResponse
{
public bool Success { get; set; }
public BackfillResult Result { get; set; } = default!;
public string TriggeredBy { get; set; } = default!;
public DateTime TriggeredAt { get; set; }
}
/// <summary>
/// The service-specific run report (BackfillResult / ReclassificationResult — types owned by the
/// Functions project, so this stays <c>object</c>; serialized by runtime type, wire-identical).
/// </summary>
public object Result { get; set; } = default!;
/// <summary>
/// Response of POST maintenance/reclassify-legacy: the reclassification run report plus
/// trigger attribution. See <see cref="BackfillJobRunResponse"/> for the shared key layout.
/// </summary>
// Declaration order == wire order.
public class ReclassifyJobRunResponse : IApiResponse
{
public bool Success { get; set; }
public ReclassificationResult Result { get; set; } = default!;
public string TriggeredBy { get; set; } = default!;
public DateTime TriggeredAt { get; set; }
}
@@ -125,17 +133,91 @@ namespace AutopilotMonitor.Shared.Models
{
public bool Success { get; set; }
public string Message { get; set; } = default!;
/// <summary>
/// The MaintenanceResult run report (type owned by the Functions project, so this stays
/// <c>object</c>; serialized by runtime type, wire-identical).
/// </summary>
public object Result { get; set; } = default!;
public MaintenanceResult Result { get; set; } = default!;
public string TriggeredBy { get; set; } = default!;
public DateTime TriggeredAt { get; set; }
}
/// <summary>
/// Run report of the one-shot OccurredUtc backfill (POST maintenance/backfill-occurred-utc).
/// </summary>
// Declaration order == wire order.
public class BackfillResult
{
public string Table { get; set; } = string.Empty;
public bool DryRun { get; set; }
public int RowsExamined { get; set; }
public int WouldWrite { get; set; }
public int Written { get; set; }
public int SkippedAlreadySet { get; set; }
public int SkippedUndecodable { get; set; }
public int Errors { get; set; }
public string? NextContinuation { get; set; }
public List<BackfillSample> Samples { get; set; } = new List<BackfillSample>();
}
/// <summary>
/// One sampled row of a backfill run. PartitionKey/RowKey are PAYLOAD here — they identify
/// the sampled storage row in a Global-Admin-only run report, not ITableEntity metadata.
/// </summary>
// Declaration order == wire order.
public class BackfillSample
{
public string PartitionKey { get; set; } = string.Empty;
public string RowKey { get; set; } = string.Empty;
public DateTime DecodedUtc { get; set; }
}
/// <summary>
/// Run report of the legacy-session reclassification job (POST maintenance/reclassify-legacy).
/// </summary>
// Declaration order == wire order.
public sealed class ReclassificationResult
{
public string Mode { get; set; } = string.Empty;
public bool DryRun { get; set; }
public int TenantsExamined { get; set; }
public int SessionsExamined { get; set; }
public int WouldChange { get; set; }
public int Changed { get; set; }
public int ToSucceeded { get; set; }
public int ToIncomplete { get; set; }
public int KeptFailed { get; set; }
public int Skipped { get; set; }
public int Errors { get; set; }
/// <summary>True when the maxSessions cap stopped the run before the backlog was exhausted — re-run to continue.</summary>
public bool CapReached { get; set; }
public List<ReclassificationSample> Samples { get; } = new List<ReclassificationSample>();
}
// Declaration order == wire order.
public sealed class ReclassificationSample
{
public string TenantId { get; set; } = string.Empty;
public string SessionId { get; set; } = string.Empty;
public string OldStatus { get; set; } = string.Empty;
public string NewStatus { get; set; } = string.Empty;
public string Reason { get; set; } = string.Empty;
}
/// <summary>Run report of a manual maintenance run (POST maintenance/trigger).</summary>
// Declaration order == wire order.
public class MaintenanceResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public string TriggeredBy { get; set; } = string.Empty;
public DateTime TriggeredAt { get; set; }
public int DurationMs { get; set; }
public bool StalledSessionsChecked { get; set; }
public bool MetricsAggregated { get; set; }
public string? AggregatedDate { get; set; }
public bool DataCleanupExecuted { get; set; }
public bool PlatformStatsRecomputed { get; set; }
public int DevicesBlockedForExcessiveData { get; set; }
public int ContactEmailsBackfilled { get; set; }
}
/// <summary>
/// One customs-archive run (one (tenantId, historyRowKey) partition) with per-source-table
/// row counts, as listed by GET global/customs-archive.
@@ -358,11 +440,34 @@ namespace AutopilotMonitor.Shared.Models
// Declaration order == wire order.
public class TenantAdminCreatedResponse : IApiResponse
{
/// <summary>
/// The stored TenantAdminEntity row (type owned by the Functions project, so this stays
/// <c>object</c>; serialized by runtime type, wire-identical).
/// </summary>
public object Admin { get; set; } = default!;
public TenantAdminRow Admin { get; set; } = default!;
}
/// <summary>
/// One tenant member (Admin / Operator / Viewer) on the wire — the item of the bare-array
/// GET tenants/{tenantId}/admins and the created member of the POST. Deliberately NOT the
/// storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the
/// pre-2026-08-31 wire carried are storage internals and were dropped from the contract
/// (no consumer read them).
/// </summary>
// Declaration order == wire order.
public class TenantAdminRow
{
/// <summary>Tenant ID (lowercase).</summary>
public string TenantId { get; set; } = string.Empty;
/// <summary>User Principal Name (lowercase).</summary>
public string Upn { get; set; } = string.Empty;
public bool IsEnabled { get; set; }
public DateTime AddedDate { get; set; }
public string AddedBy { get; set; } = string.Empty;
/// <summary>Role: "Admin", "Operator", "Viewer". A null (legacy pre-role row) omits the key and means Admin.</summary>
public string? Role { get; set; }
/// <summary>Whether this Operator can manage bootstrap tokens (only relevant for Operator).</summary>
public bool CanManageBootstrapTokens { get; set; }
}
/// <summary>Response of GET global/tenant-groups: every group with tenants + assignees.</summary>
@@ -86,11 +86,45 @@ namespace AutopilotMonitor.Shared.Models
public int Count { get; set; }
public int WritableCount { get; set; }
/// <summary>
/// Schema entries (<c>TenantConfigFieldSchema</c> records, defined in the Functions
/// project — typed as object here because Shared cannot reference it).
/// </summary>
public IReadOnlyList<object> Fields { get; set; } = default!;
public IReadOnlyList<TenantConfigFieldSchema> Fields { get; set; } = default!;
}
/// <summary>
/// One entry of the tenant-config field schema (built by TenantConfigPatchService.BuildFieldsSchema).
/// Serialized camelCase; null Format/Reason keys are omitted (WhenWritingNull) — the null and
/// set cases are pinned by ConfigWireParityTests.
/// </summary>
// Declaration order == wire order (matches the former positional-record constructor order).
public sealed class TenantConfigFieldSchema
{
public string Name { get; set; }
public string Type { get; set; }
public string? Format { get; set; }
public bool Nullable { get; set; }
public bool Writable { get; set; }
public string? Reason { get; set; }
public bool GaOnly { get; set; }
public bool RevertProtected { get; set; }
public TenantConfigFieldSchema(
string name,
string type,
string? format,
bool nullable,
bool writable,
string? reason,
bool gaOnly,
bool revertProtected)
{
Name = name;
Type = type;
Format = format;
Nullable = nullable;
Writable = writable;
Reason = reason;
GaOnly = gaOnly;
RevertProtected = revertProtected;
}
}
/// <summary>
@@ -4,6 +4,41 @@ using AutopilotMonitor.Shared.DataAccess;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Success body of GET auth/me: the caller's resolved identity, roles and effective
/// entitlement flags. Blocked outcomes (TenantSuspended / PendingActivation) are error
/// shapes and stay anonymous by design.
/// </summary>
// Declaration order == wire order.
public class AuthMeResponse : IApiResponse
{
public string TenantId { get; set; } = default!;
public string Upn { get; set; } = default!;
public string DisplayName { get; set; } = default!;
public string ObjectId { get; set; } = default!;
public bool IsGlobalAdmin { get; set; }
public bool IsGlobalReader { get; set; }
public bool IsTenantAdmin { get; set; }
/// <summary>True when the caller holds delegated ("MSP") assignments to other tenants.</summary>
public bool IsDelegated { get; set; }
/// <summary>The OTHER tenants this caller may manage; empty for non-delegated callers.</summary>
public IReadOnlyCollection<string> DelegatedTenantIds { get; set; } = default!;
/// <summary>Tenant role (Admin / Operator / Viewer); the key is omitted for a roleless caller.</summary>
public string? Role { get; set; }
public bool CanManageBootstrapTokens { get; set; }
public bool HasMcpAccess { get; set; }
/// <summary>"primary" or "legacy" — which app registration this tenant is homed on.</summary>
public string HomedApp { get; set; } = default!;
public bool BootstrapTokenEnabled { get; set; }
public bool UnrestrictedModeEnabled { get; set; }
}
/// <summary>
/// Response of GET auth/is-global-admin: whether the caller holds the Global Admin
/// platform role, echoing the caller's UPN.
@@ -18,24 +53,38 @@ namespace AutopilotMonitor.Shared.Models
}
/// <summary>
/// Response of GET auth/global-admins: every Global Admin/Reader row. Items are
/// <c>GlobalAdminEntity</c> table entities (backend-project type, serialized by runtime
/// type — includes the ITableEntity keys, exactly as the anonymous site did).
/// Response of GET auth/global-admins: every Global Admin/Reader row.
/// </summary>
// Declaration order == wire order.
public class GetGlobalAdminsResponse : IApiResponse
{
public IReadOnlyList<object> Admins { get; set; } = default!;
public IReadOnlyList<GlobalAdminRow> Admins { get; set; } = default!;
}
/// <summary>
/// Response of POST auth/global-admins (201): the created <c>GlobalAdminEntity</c>
/// (backend-project type, serialized by runtime type).
/// Response of POST auth/global-admins (201): the created row.
/// </summary>
// Declaration order == wire order.
public class AddGlobalAdminResponse : IApiResponse
{
public object Admin { get; set; } = default!;
public GlobalAdminRow Admin { get; set; } = default!;
}
/// <summary>
/// One Global Admin/Reader row on the wire. Deliberately NOT the storage entity: the
/// ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire
/// carried are storage internals and were dropped from the contract (no consumer read them).
/// </summary>
// Declaration order == wire order.
public class GlobalAdminRow
{
/// <summary>User Principal Name (lowercase).</summary>
public string Upn { get; set; } = string.Empty;
public bool IsEnabled { get; set; }
public DateTime AddedDate { get; set; }
public string AddedBy { get; set; } = string.Empty;
/// <summary>"GlobalAdmin" or "GlobalReader" (legacy empty rows are normalized to GlobalAdmin).</summary>
public string Role { get; set; } = string.Empty;
}
/// <summary>
@@ -54,8 +103,7 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>
/// Response of GET health/detailed: the full system health report (always 200; per-check
/// status is in the body). Items are <c>HealthCheck</c> objects (backend-project type,
/// serialized by runtime type); non-GA callers get a filtered list.
/// status is in the body). Non-GA callers get a filtered check list.
/// </summary>
// Declaration order == wire order.
public class DetailedHealthCheckResponse : IApiResponse
@@ -63,21 +111,62 @@ namespace AutopilotMonitor.Shared.Models
public string Service { get; set; } = default!;
public DateTime Timestamp { get; set; }
public string OverallStatus { get; set; } = default!;
public IReadOnlyList<object> Checks { get; set; } = default!;
public IReadOnlyList<HealthCheck> Checks { get; set; } = default!;
public string Version { get; set; } = default!;
public string CommitHash { get; set; } = default!;
public DateTime BuildUtc { get; set; }
}
/// <summary>
/// Response of GET health/mcp: the standalone MCP-server reachability probe. The check is
/// a <c>HealthCheck</c> object (backend-project type, serialized by runtime type).
/// Response of GET health/mcp: the standalone MCP-server reachability probe.
/// </summary>
// Declaration order == wire order.
public class McpHealthCheckResponse : IApiResponse
{
public DateTime Timestamp { get; set; }
public object Check { get; set; } = default!;
public HealthCheck Check { get; set; } = default!;
}
/// <summary>
/// One health check result inside GET health/detailed / health/mcp. Details is a
/// heterogeneous per-check bag by design (endpoint URLs only for Global Admins);
/// a null Details key is omitted (WhenWritingNull).
/// </summary>
// Declaration order == wire order.
public class HealthCheck
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Status { get; set; } = "unknown";
public string Message { get; set; } = string.Empty;
public Dictionary<string, object>? Details { get; set; }
}
/// <summary>
/// Body of GET auth/mcp (200 when allowed, 403 when denied — same shape on both).
/// The four platform/delegated keys are emitted ONLY when the caller actually holds the
/// tier (null ⇒ key omitted): ordinary tenant users must not learn a platform tier exists,
/// and <see cref="IsGlobalAdmin"/> is only ever emitted as literal <c>true</c>.
/// </summary>
// Declaration order == wire order (matches the former Dictionary insertion order).
public class CheckMcpAccessResponse : IApiResponse
{
public bool Allowed { get; set; }
public string Upn { get; set; } = default!;
public string AccessGrant { get; set; } = default!;
public string Reason { get; set; } = default!;
/// <summary>Back-compat / write-tier hint for the MCP access-guard; true or omitted, never false.</summary>
public bool? IsGlobalAdmin { get; set; }
/// <summary>"GlobalAdmin" | "GlobalReader"; omitted without a platform role.</summary>
public string? GlobalRole { get; set; }
/// <summary>Managed tenant ids (lowercase) of a delegated (MSP) caller; omitted otherwise.</summary>
public IReadOnlyCollection<string>? DelegatedTenantIds { get; set; }
/// <summary>"DelegatedAdmin" | "DelegatedReader"; omitted without delegated scope.</summary>
public string? DelegatedRole { get; set; }
}
/// <summary>
@@ -143,23 +143,19 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>
/// Per-tenant session-status tally envelope shared by MetricsSummary and
/// MetricsSummaryGlobal. <see cref="Summary"/> items are repository-built rows shaped
/// like <see cref="MetricsSummaryTenantItem"/>.
/// MetricsSummaryGlobal.
/// </summary>
// Declaration order == wire order.
public class MetricsSummaryResponse : IApiResponse
{
public bool Success { get; set; }
[ProjectedItems(typeof(MetricsSummaryTenantItem))]
public IReadOnlyList<object> Summary { get; set; } = default!;
public IReadOnlyList<MetricsSummaryTenantItem> Summary { get; set; } = default!;
public int WindowDays { get; set; }
}
/// <summary>
/// Wire shape of one per-tenant status tally in <see cref="MetricsSummaryResponse"/>
/// (documentation type — the repository still builds the rows; every key is always present).
/// One per-tenant status tally in <see cref="MetricsSummaryResponse"/>. WindowDays is
/// repeated per item (envelope carries it too — historical wire shape, kept for parity).
/// </summary>
// Declaration order == wire order.
public class MetricsSummaryTenantItem
@@ -1,17 +1,32 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Shared response of GET global/notifications and GET notifications: the active
/// (non-dismissed) notifications visible to the caller, newest first. Items are
/// <c>GlobalNotificationDto</c> objects (backend-project type, serialized by runtime type).
/// (non-dismissed) notifications visible to the caller, newest first.
/// </summary>
// Declaration order == wire order.
public class NotificationListResponse : IApiResponse
{
public bool Success { get; set; }
public IReadOnlyList<object> Notifications { get; set; } = default!;
public IReadOnlyList<GlobalNotificationDto> Notifications { get; set; } = default!;
}
/// <summary>
/// One in-app notification (global pool or tenant-scoped; both endpoints share this DTO).
/// A null Href key is omitted (WhenWritingNull).
/// </summary>
// Declaration order == wire order.
public class GlobalNotificationDto
{
public string Id { get; set; } = string.Empty;
public string Type { get; set; } = "info";
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string? Href { get; set; }
public DateTime CreatedAt { get; set; }
}
/// <summary>
@@ -54,12 +54,93 @@ namespace AutopilotMonitor.Shared.Models
public bool Success { get; set; }
public string SessionId { get; set; } = default!;
/// <summary>
/// The <c>RuleDryRun</c> trace (AutopilotMonitor.Functions.Services) — typed
/// <c>object</c> here only because that class lives in the Functions assembly;
/// System.Text.Json serializes the runtime type, so the wire shape is unchanged.
/// </summary>
public object Result { get; set; } = default!;
/// <summary>The full diagnostic trace of the evaluation.</summary>
public RuleDryRun Result { get; set; } = default!;
}
/// <summary>Verdict strings for <see cref="RuleDryRun.Verdict"/>. Stable API contract.</summary>
public static class RuleDryRunVerdict
{
public const string Fired = "fired";
public const string SkippedByPrecondition = "skipped_by_precondition";
public const string RequiredConditionNotMet = "required_condition_not_met";
public const string NoConditionsMatched = "no_conditions_matched";
public const string BelowConfidenceThreshold = "below_confidence_threshold";
public const string NoEvents = "no_events";
}
/// <summary>Full diagnostic trace of one dry-run evaluation. Serialized camelCase to clients.</summary>
// Declaration order == wire order.
public sealed class RuleDryRun
{
public string Verdict { get; set; } = string.Empty;
/// <summary>Number of events in the session the rule was evaluated against.</summary>
public int EventCount { get; set; }
public List<RuleDryRunPrecondition> Preconditions { get; } = new List<RuleDryRunPrecondition>();
public List<RuleDryRunCondition> Conditions { get; } = new List<RuleDryRunCondition>();
/// <summary>Empty unless all required conditions were met (mirrors the production path,
/// which never reaches factor evaluation otherwise).</summary>
public List<RuleDryRunFactor> ConfidenceFactors { get; } = new List<RuleDryRunFactor>();
public int BaseConfidence { get; set; }
/// <summary>base + matched factor weights, capped at 100. Null when the evaluation ended
/// before the confidence stage (precondition skip / required miss / nothing matched).</summary>
public int? FinalConfidence { get; set; }
public int ConfidenceThreshold { get; set; }
/// <summary>True only for verdict "fired" AND the rule's effective MarkSessionAsFailed flag.
/// The dry-run itself never touches the session.</summary>
public bool WouldMarkSessionAsFailed { get; set; }
/// <summary>The evidence map exactly as the production path would persist it on a
/// RuleResult — keys are condition signals (plus factor_* markers). Clients use it to
/// preview {{token}} interpolation of explanation/remediation. Values are heterogeneous
/// evidence objects by design.</summary>
public Dictionary<string, object>? MatchedConditions { get; set; }
}
// Declaration order == wire order.
public sealed class RuleDryRunPrecondition
{
public string Source { get; set; } = string.Empty;
public string EventType { get; set; } = string.Empty;
public string? DataField { get; set; }
public string? Operator { get; set; }
public string? Value { get; set; }
public bool Passed { get; set; }
}
// Declaration order == wire order.
public sealed class RuleDryRunCondition
{
public string Signal { get; set; } = string.Empty;
public string Source { get; set; } = string.Empty;
public string? EventType { get; set; }
public bool Required { get; set; }
public bool Matched { get; set; }
/// <summary>Matched: the evidence dictionary (eventId, timestamp, field, value, …).
/// Not matched: the evaluator's reason string (e.g. "no matching events").</summary>
public object? Evidence { get; set; }
/// <summary>How many session events have this condition's eventType at all — the first
/// thing an author checks when a condition unexpectedly doesn't match. Null when the
/// condition has no eventType.</summary>
public int? MatchingEventCount { get; set; }
}
// Declaration order == wire order.
public sealed class RuleDryRunFactor
{
public string Signal { get; set; } = string.Empty;
public string Condition { get; set; } = string.Empty;
public int Weight { get; set; }
public bool Matched { get; set; }
}
/// <summary>
@@ -151,12 +232,18 @@ namespace AutopilotMonitor.Shared.Models
// Declaration order == wire order.
public class GetPreviewWhitelistResponse : IApiResponse
{
/// <summary>
/// <c>PreviewWhitelistEntity</c> rows (AutopilotMonitor.Functions.DataAccess.TableStorage)
/// — element-typed <c>object</c> here only because that entity lives in the Functions
/// assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged.
/// </summary>
public IReadOnlyList<object> Tenants { get; set; } = default!;
public IReadOnlyList<PreviewWhitelistTenantEntry> Tenants { get; set; } = default!;
}
/// <summary>
/// One approved tenant on the wire. Deliberately NOT the storage entity: the pre-2026-08-31
/// wire carried synthetic <c>PreviewWhitelistEntity</c> rows whose only real datum was the
/// tenant id in <c>partitionKey</c> (plus garbage defaults) — the contract is now just the id.
/// </summary>
// Declaration order == wire order.
public class PreviewWhitelistTenantEntry
{
public string TenantId { get; set; } = string.Empty;
}
/// <summary>
@@ -5,6 +5,7 @@ import { useAuth } from "../../contexts/AuthContext";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import type { AdminConfiguration, OpsAlertRule } from "@/types/adminConfig";
import type { GetPreviewWhitelistResponse } from "@/utils/wire-types.generated";
// Re-export so existing `import { AdminConfiguration } from "../AdminConfigContext"` consumers keep working
export type { AdminConfiguration, OpsAlertRule };
@@ -297,9 +298,9 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setTenants(data);
if (previewRes.ok) {
const previewData = await previewRes.json();
const previewData = (await previewRes.json()) as GetPreviewWhitelistResponse;
const approvedIds = new Set<string>(
(previewData.tenants || []).map((t: { partitionKey: string }) => t.partitionKey)
(previewData.tenants || []).map((t) => t.tenantId)
);
setPreviewApproved(approvedIds);
}
@@ -4,16 +4,10 @@ import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import type { TenantAdminRow } from "@/utils/wire-types.generated";
interface TenantAdmin {
tenantId: string;
upn: string;
isEnabled: boolean;
addedDate: string;
addedBy: string;
role: string | null;
canManageBootstrapTokens: boolean;
}
// Wire type is generated from the backend DTO ("role" is absent for legacy pre-role rows).
type TenantAdmin = TenantAdminRow;
interface TenantAdminSectionProps {
tenantId: string;
@@ -8,31 +8,14 @@ import { useState, useEffect, useCallback } from 'react';
import * as signalR from '@microsoft/signalr';
import { api } from '@/lib/api';
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
interface HealthCheck {
name: string;
description: string;
status: string;
message: string;
details?: Record<string, unknown>;
}
interface HealthCheckResult {
service: string;
timestamp: string;
overallStatus: string;
checks: HealthCheck[];
version?: string;
commitHash?: string;
buildUtc?: string;
}
import type { DetailedHealthCheckResponse, HealthCheck, McpHealthCheckResponse } from '@/utils/wire-types.generated';
export default function HealthCheckPage() {
const { user, getAccessToken } = useAuth();
const { addNotification } = useNotifications();
const { connectionState, isConnected, joinedGroups, joinGroup } = useSignalR();
const { tenantId } = useTenant();
const [healthResult, setHealthResult] = useState<HealthCheckResult | null>(null);
const [healthResult, setHealthResult] = useState<DetailedHealthCheckResponse | null>(null);
const [loading, setLoading] = useState(false);
const [hasRun, setHasRun] = useState(false);
const [groupJoinTest, setGroupJoinTest] = useState<'idle' | 'testing' | 'success' | 'failed'>('idle');
@@ -60,7 +43,7 @@ export default function HealthCheckPage() {
return;
}
const data = await response.json();
const data = (await response.json()) as DetailedHealthCheckResponse;
setHealthResult(data);
} catch (error) {
if (error instanceof TokenExpiredError) {
@@ -94,8 +77,8 @@ export default function HealthCheckPage() {
});
return;
}
const data = await response.json();
if (data?.check) setMcpCheck(data.check as HealthCheck);
const data = (await response.json()) as McpHealthCheckResponse;
if (data?.check) setMcpCheck(data.check);
} catch (error) {
// Network/token errors: surface on the card itself, never as a blocking page error.
setMcpCheck({
@@ -23,7 +23,7 @@ interface AdminManagementSectionProps {
onUpdatePermissions: (upn: string, role: string, canManageBootstrapTokens: boolean) => void;
}
function getRoleBadge(role: string | null) {
function getRoleBadge(role: string | null | undefined) {
const effectiveRole = role ?? "Admin";
switch (effectiveRole) {
case "Admin":
@@ -139,12 +139,5 @@ export interface TenantConfiguration {
trialConsumed?: boolean;
}
export interface TenantAdmin {
tenantId: string;
upn: string;
isEnabled: boolean;
addedDate: string;
addedBy: string;
role: string | null;
canManageBootstrapTokens: boolean;
}
// Wire type is generated from the backend DTO ("role" is absent for legacy pre-role rows).
export type { TenantAdminRow as TenantAdmin } from "@/utils/wire-types.generated";
@@ -19,6 +19,7 @@ import {
} from '@/lib/authApp';
import { api } from '@/lib/api';
import { trackEvent } from '@/lib/appInsights';
import type { AuthMeResponse } from '@/types/auth';
// Initialize MSAL instance for the ACTIVE app registration (dual app-reg window: the
// selection was decided at module boot in lib/msalConfig.ts; app switches always reload).
@@ -33,7 +34,7 @@ let msalReady = false;
// fetch failed / token acquisition failed.
// Runs as a fire-and-forget side-effect — MUST NOT block msalInitPromise,
// otherwise a cold backend would keep the UI on a white screen.
let prefetchedAuthMePromise: Promise<Record<string, unknown> | null> | null = null;
let prefetchedAuthMePromise: Promise<AuthMeResponse | null> | null = null;
/**
* Safety net of the dual app-reg model, for the rare browser that lands on an app its tenant
@@ -108,7 +109,7 @@ const msalInitPromise = msalInstance
headers: { 'Authorization': `Bearer ${tokenResponse.accessToken}` },
signal: AbortSignal.timeout(8000),
});
return res.ok ? (await res.json()) as Record<string, unknown> : null;
return res.ok ? (await res.json()) as AuthMeResponse : null;
}).catch(() => null);
}
})
@@ -158,33 +159,23 @@ async function safeAcquireTokenRedirect(
}
}
interface UserInfo {
displayName: string;
upn: string;
tenantId: string;
objectId: string;
isGlobalAdmin: boolean;
/**
* Read-only platform tier: cross-tenant VISIBILITY like a Global Admin but no platform mutations.
* ADDITIVE — a user may be both a GlobalReader and their own tenant's Admin (then isTenantAdmin is
* also true and they keep edit rights on their own tenant). Use {@link AuthContextType.hasGlobalScope}
* for visibility gating and isGlobalAdmin for platform-mutation gating.
*/
isGlobalReader: boolean;
isTenantAdmin: boolean;
/**
* Delegated ("MSP") admin: the caller manages a SUBSET of OTHER tenants (read-only this phase) — the
* "scoped global" tier between a single-tenant member and a platform Global Admin. True iff
* {@link delegatedTenantIds} is non-empty. Use {@link AuthContextType.hasFleetScope} for fleet/switcher gating.
*/
isDelegated: boolean;
/** The tenant IDs this caller manages as a delegated admin (lowercase). Empty for non-delegated users. */
delegatedTenantIds: string[];
/**
* Client-side auth state, derived from the generated auth/me wire type — a backend key
* rename goes tsc-red here instead of silently degrading to fallbacks.
* Deviations from the wire: `role` is an explicit null (the wire omits the key for a
* roleless caller), and `homedApp` is not carried (it is learned into localStorage via
* {@link learnHomedAppFromAuthMe}, never React state).
*
* Semantics reminders:
* - isGlobalReader: read-only platform tier — cross-tenant VISIBILITY like a Global Admin
* but no platform mutations. ADDITIVE (a GlobalReader can also be their own tenant's
* Admin). Use {@link AuthContextType.hasGlobalScope} for visibility gating and
* isGlobalAdmin for platform-mutation gating.
* - isDelegated / delegatedTenantIds: delegated ("MSP") admin managing a SUBSET of other
* tenants — use {@link AuthContextType.hasFleetScope} for fleet/switcher gating.
*/
interface UserInfo extends Omit<AuthMeResponse, 'role' | 'homedApp'> {
role: 'Admin' | 'Operator' | 'Viewer' | null;
canManageBootstrapTokens: boolean;
hasMcpAccess: boolean;
bootstrapTokenEnabled: boolean;
unrestrictedModeEnabled: boolean;
}
interface AuthContextType {
@@ -214,7 +205,7 @@ interface AuthContextType {
* This is also how an operator flip propagates: flip → next login of every user runs via the
* new app, at which point the tenant's admin consent already exists.
*/
function learnHomedAppFromAuthMe(data: Record<string, unknown>): void {
function learnHomedAppFromAuthMe(data: AuthMeResponse): void {
const homedApp = data.homedApp;
if (homedApp === 'legacy' || homedApp === 'primary') {
// Change-only trace: this browser just learned its tenant was flipped — the propagation
@@ -226,6 +217,30 @@ function learnHomedAppFromAuthMe(data: Record<string, unknown>): void {
}
}
/**
* Maps a successful auth/me body onto client auth state. Token-claim fallbacks cover the
* cold-start edge where a field arrives empty; the shape itself is compiler-checked
* against the generated wire type.
*/
function toUserInfo(data: AuthMeResponse, account: AccountInfo): UserInfo {
return {
displayName: data.displayName || account.name || '',
upn: data.upn || account.username || '',
tenantId: data.tenantId || account.tenantId || '',
objectId: data.objectId || account.homeAccountId || '',
isGlobalAdmin: data.isGlobalAdmin || false,
isGlobalReader: data.isGlobalReader || false,
isTenantAdmin: data.isTenantAdmin || false,
isDelegated: data.isDelegated || false,
delegatedTenantIds: data.delegatedTenantIds || [],
role: data.role ?? null,
canManageBootstrapTokens: data.canManageBootstrapTokens || false,
hasMcpAccess: data.hasMcpAccess || false,
bootstrapTokenEnabled: data.bootstrapTokenEnabled || false,
unrestrictedModeEnabled: data.unrestrictedModeEnabled || false,
};
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
/**
@@ -255,22 +270,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
prefetchedAuthMePromise = null;
const data = await pending;
if (data) learnHomedAppFromAuthMe(data);
if (data) return {
displayName: (data.displayName as string) || account.name || '',
upn: (data.upn as string) || account.username || '',
tenantId: (data.tenantId as string) || account.tenantId || '',
objectId: (data.objectId as string) || account.homeAccountId || '',
isGlobalAdmin: (data.isGlobalAdmin as boolean) || false,
isGlobalReader: (data.isGlobalReader as boolean) || false,
isTenantAdmin: (data.isTenantAdmin as boolean) || false,
isDelegated: (data.isDelegated as boolean) || false,
delegatedTenantIds: (data.delegatedTenantIds as string[]) || [],
role: (data.role as 'Admin' | 'Operator' | 'Viewer' | null) || null,
canManageBootstrapTokens: (data.canManageBootstrapTokens as boolean) || false,
hasMcpAccess: (data.hasMcpAccess as boolean) || false,
bootstrapTokenEnabled: (data.bootstrapTokenEnabled as boolean) || false,
unrestrictedModeEnabled: (data.unrestrictedModeEnabled as boolean) || false,
};
if (data) return toUserInfo(data, account);
}
// Get access token for API
@@ -334,31 +334,16 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
throw new Error(`Failed to fetch user info: ${response.statusText}`);
}
const data = await response.json();
const data = (await response.json()) as AuthMeResponse;
// A successful auth/me means the tenant is (now) activated — clear any pending
// state so the activation page's poll can redirect into the portal.
setActivationPending(false);
setActivationMessage('');
learnHomedAppFromAuthMe(data as Record<string, unknown>);
learnHomedAppFromAuthMe(data);
return {
displayName: data.displayName || account.name || '',
upn: data.upn || account.username || '',
tenantId: data.tenantId || account.tenantId || '',
objectId: data.objectId || account.homeAccountId || '',
isGlobalAdmin: data.isGlobalAdmin || false,
isGlobalReader: data.isGlobalReader || false,
isTenantAdmin: data.isTenantAdmin || false,
isDelegated: data.isDelegated || false,
delegatedTenantIds: data.delegatedTenantIds || [],
role: data.role || null,
canManageBootstrapTokens: data.canManageBootstrapTokens || false,
hasMcpAccess: data.hasMcpAccess || false,
bootstrapTokenEnabled: data.bootstrapTokenEnabled || false,
unrestrictedModeEnabled: data.unrestrictedModeEnabled || false,
};
return toUserInfo(data, account);
} catch (error) {
// If the refresh token is expired or consent is required, redirect to
// interactive login immediately instead of falling back to stale claims.
@@ -5,15 +5,10 @@ import { useAuth } from './AuthContext';
import { useSignalR } from './SignalRContext';
import { api } from '@/lib/api';
import { authenticatedFetch } from '@/lib/authenticatedFetch';
import type { GlobalNotificationDto, NotificationListResponse } from '@/utils/wire-types.generated';
export interface GlobalNotification {
id: string;
type: string;
title: string;
message: string;
href?: string;
createdAt: string;
}
// Wire type is generated from the backend DTO; the alias keeps the established local name.
export type GlobalNotification = GlobalNotificationDto;
interface GlobalNotificationContextType {
notifications: GlobalNotification[];
@@ -51,7 +46,7 @@ export function GlobalNotificationProvider({ children }: { children: React.React
getAccessToken,
);
if (response.ok) {
const data = await response.json();
const data = (await response.json()) as NotificationListResponse;
setNotifications(data.notifications ?? []);
}
} catch {
@@ -6,15 +6,10 @@ import { useSignalR } from './SignalRContext';
import { canFetchTenantNotifications } from './tenantNotificationsGate';
import { api } from '@/lib/api';
import { authenticatedFetch } from '@/lib/authenticatedFetch';
import type { GlobalNotificationDto, NotificationListResponse } from '@/utils/wire-types.generated';
export interface TenantNotification {
id: string;
type: string;
title: string;
message: string;
href?: string;
createdAt: string;
}
// Both notification endpoints share one backend DTO; the alias keeps the established local name.
export type TenantNotification = GlobalNotificationDto;
interface TenantNotificationContextType {
tenantNotifications: TenantNotification[];
@@ -54,7 +49,7 @@ export function TenantNotificationProvider({ children }: { children: React.React
getAccessToken,
);
if (response.ok) {
const data = await response.json();
const data = (await response.json()) as NotificationListResponse;
setTenantNotifications(data.notifications ?? []);
}
} catch {
@@ -0,0 +1,16 @@
// The auth/me wire shape is GENERATED from the C# contract (AuthMeResponse in
// AutopilotMonitor.Shared → utils/wire-types.generated.ts). This module narrows the
// two plain-string wire slots to the canonical literals the backend actually emits,
// so a backend rename or type change goes tsc-red in every consumer.
import type { AuthMeResponse as WireAuthMeResponse } from "@/utils/wire-types.generated";
/** Tenant role emitted by auth/me ("role" is absent on the wire for a roleless caller). */
export type TenantRole = "Admin" | "Operator" | "Viewer";
/** Which app registration the tenant is homed on (dual app-reg operation model). */
export type HomedApp = "primary" | "legacy";
export type AuthMeResponse = Omit<WireAuthMeResponse, "role" | "homedApp"> & {
role?: TenantRole;
homedApp: HomedApp;
};
File diff suppressed because it is too large Load Diff
@@ -17,9 +17,9 @@ export interface ActiveUserItem {
secondsAgo: number;
}
/** Response of POST auth/global-admins (201): the created GlobalAdminEntity (backend-project type, serialized by runtime type). */
/** Response of POST auth/global-admins (201): the created row. */
export interface AddGlobalAdminResponse {
admin: unknown;
admin: GlobalAdminRow;
}
/** Response of POST vulnerability/ignored-software: how many entries were added. */
@@ -540,6 +540,29 @@ export interface AuditLogListResponse {
nextLink?: string;
}
/** Success body of GET auth/me: the caller's resolved identity, roles and effective entitlement flags. Blocked outcomes (TenantSuspended / PendingActivation) are error shapes and stay anonymous by design. */
export interface AuthMeResponse {
tenantId: string;
upn: string;
displayName: string;
objectId: string;
isGlobalAdmin: boolean;
isGlobalReader: boolean;
isTenantAdmin: boolean;
/** True when the caller holds delegated ("MSP") assignments to other tenants. */
isDelegated: boolean;
/** The OTHER tenants this caller may manage; empty for non-delegated callers. */
delegatedTenantIds: string[];
/** Tenant role (Admin / Operator / Viewer); the key is omitted for a roleless caller. */
role?: string;
canManageBootstrapTokens: boolean;
hasMcpAccess: boolean;
/** "primary" or "legacy" — which app registration this tenant is homed on. */
homedApp: string;
bootstrapTokenEnabled: boolean;
unrestrictedModeEnabled: boolean;
}
/** Response of POST vulnerability/cpe-mapping/auto-resolve: per-item outcomes plus totals. */
export interface AutoResolveCpeMappingResponse {
resolved: AutoResolveResultItem[];
@@ -588,6 +611,35 @@ export interface AutopilotConsentUrlResponse {
willAutoFlipHoming: boolean;
}
/** Response of POST maintenance/backfill-occurred-utc: the backfill run report plus trigger attribution. Key layout is identical to ReclassifyJobRunResponse — the two one-shot job triggers share one wire shape, split into two classes only for the typed slot. */
export interface BackfillJobRunResponse {
success: boolean;
result: BackfillResult;
triggeredBy: string;
triggeredAt: string;
}
/** Run report of the one-shot OccurredUtc backfill (POST maintenance/backfill-occurred-utc). */
export interface BackfillResult {
table: string;
dryRun: boolean;
rowsExamined: number;
wouldWrite: number;
written: number;
skippedAlreadySet: number;
skippedUndecodable: number;
errors: number;
nextContinuation?: string;
samples: BackfillSample[];
}
/** One sampled row of a backfill run. PartitionKey/RowKey are PAYLOAD here — they identify the sampled storage row in a Global-Admin-only run report, not ITableEntity metadata. */
export interface BackfillSample {
partitionKey: string;
rowKey: string;
decodedUtc: string;
}
/** Response of POST devices/block: block/kill acknowledgement. */
export interface BlockDeviceResponse {
success: boolean;
@@ -648,6 +700,22 @@ export interface BlockingAppInterval {
seconds: number;
}
/** Body of GET auth/mcp (200 when allowed, 403 when denied — same shape on both). The four platform/delegated keys are emitted ONLY when the caller actually holds the tier (null ⇒ key omitted): ordinary tenant users must not learn a platform tier exists, and IsGlobalAdmin is only ever emitted as literal true. */
export interface CheckMcpAccessResponse {
allowed: boolean;
upn: string;
accessGrant: string;
reason: string;
/** Back-compat / write-tier hint for the MCP access-guard; true or omitted, never false. */
isGlobalAdmin?: boolean;
/** "GlobalAdmin" | "GlobalReader"; omitted without a platform role. */
globalRole?: string;
/** Managed tenant ids (lowercase) of a delegated (MSP) caller; omitted otherwise. */
delegatedTenantIds?: string[];
/** "DelegatedAdmin" | "DelegatedReader"; omitted without delegated scope. */
delegatedRole?: string;
}
/** A factor that increases confidence when matched */
export interface ConfidenceFactor {
/** Descriptive name for this factor */
@@ -851,12 +919,12 @@ export interface DeploymentTypeMetrics {
whiteGlovePercentage: number;
}
/** Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Items are HealthCheck objects (backend-project type, serialized by runtime type); non-GA callers get a filtered list. */
/** Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Non-GA callers get a filtered check list. */
export interface DetailedHealthCheckResponse {
service: string;
timestamp: string;
overallStatus: string;
checks: unknown[];
checks: HealthCheck[];
version: string;
commitHash: string;
buildUtc: string;
@@ -1062,8 +1130,8 @@ export interface DistressReportListResponse {
export interface DryRunAnalyzeRuleResponse {
success: boolean;
sessionId: string;
/** The RuleDryRun trace (AutopilotMonitor.Functions.Services) — typed object here only because that class lives in the Functions assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged. */
result: unknown;
/** The full diagnostic trace of the evaluation. */
result: RuleDryRun;
}
export interface EfficiencyOffender {
@@ -1393,9 +1461,9 @@ export interface GetDeviceHistoryResponse {
attemptNumber?: number;
}
/** Response of GET auth/global-admins: every Global Admin/Reader row. Items are GlobalAdminEntity table entities (backend-project type, serialized by runtime type — includes the ITableEntity keys, exactly as the anonymous site did). */
/** Response of GET auth/global-admins: every Global Admin/Reader row. */
export interface GetGlobalAdminsResponse {
admins: unknown[];
admins: GlobalAdminRow[];
}
/** Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server paginates over the summaries key — its name is wire-critical. */
@@ -1485,8 +1553,7 @@ export interface GetPreviewNotificationEmailResponse {
/** Response of GET preview/whitelist: every approved tenant (Global Admin only). */
export interface GetPreviewWhitelistResponse {
/** PreviewWhitelistEntity rows (AutopilotMonitor.Functions.DataAccess.TableStorage) — element-typed object here only because that entity lives in the Functions assembly; System.Text.Json serializes the runtime type, so the wire shape is unchanged. */
tenants: unknown[];
tenants: PreviewWhitelistTenantEntry[];
}
/** Session ids where a rule produced a result within the window (GetRuleHitSessions) — powers the dashboard's ?ruleId= deep link. */
@@ -1632,8 +1699,7 @@ export interface GetSessionTimeAttributionResponse {
export interface GetTenantConfigFieldsSchemaResponse {
count: number;
writableCount: number;
/** Schema entries (TenantConfigFieldSchema records, defined in the Functions project — typed as object here because Shared cannot reference it). */
fields: unknown[];
fields: TenantConfigFieldSchema[];
}
/** Per-tenant deletion-manifest tree envelope (GetTenantDeletionManifests). */
@@ -1694,6 +1760,17 @@ export interface GetVulnerabilitySyncStatusResponse {
epssLastRun?: EpssRefreshRunWire;
}
/** One Global Admin/Reader row on the wire. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them). */
export interface GlobalAdminRow {
/** User Principal Name (lowercase). */
upn: string;
isEnabled: boolean;
addedDate: string;
addedBy: string;
/** "GlobalAdmin" or "GlobalReader" (legacy empty rows are normalized to GlobalAdmin). */
role: string;
}
/** Global average benchmarks for geographic comparison. */
export interface GlobalAverages {
avgDurationMinutes: number;
@@ -1713,6 +1790,16 @@ export interface GlobalAverages {
totalDoBytesFromHttp: number;
}
/** One in-app notification (global pool or tenant-scoped; both endpoints share this DTO). A null Href key is omitted (WhenWritingNull). */
export interface GlobalNotificationDto {
id: string;
type: string;
title: string;
message: string;
href?: string;
createdAt: string;
}
/** One feature row of the graph-permissions status matrix: the feature identifier, the granted verdict (null while the snapshot is transient) and the Graph application permissions the feature requires. */
export interface GraphFeatureStatusItem {
name: string;
@@ -1753,6 +1840,15 @@ export interface HardwareRejectedResponse {
dataQualityNotice: string;
}
/** One health check result inside GET health/detailed / health/mcp. Details is a heterogeneous per-check bag by design (endpoint URLs only for Global Admins); a null Details key is omitted (WhenWritingNull). */
export interface HealthCheck {
name: string;
description: string;
status: string;
message: string;
details?: Record<string, unknown>;
}
/** Response of GET health: liveness probe plus the backend build identity. */
export interface HealthCheckResponse {
status: string;
@@ -2100,19 +2196,26 @@ export interface LogPatternTestResult {
readonly notes: string[];
}
/** Shared response of the one-shot maintenance job triggers (POST maintenance/backfill-occurred-utc, POST maintenance/reclassify-legacy): the service's run report plus trigger attribution. */
export interface MaintenanceJobRunResponse {
/** Run report of a manual maintenance run (POST maintenance/trigger). */
export interface MaintenanceResult {
success: boolean;
/** The service-specific run report (BackfillResult / ReclassificationResult — types owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */
result: unknown;
error?: string;
triggeredBy: string;
triggeredAt: string;
durationMs: number;
stalledSessionsChecked: boolean;
metricsAggregated: boolean;
aggregatedDate?: string;
dataCleanupExecuted: boolean;
platformStatsRecomputed: boolean;
devicesBlockedForExcessiveData: number;
contactEmailsBackfilled: number;
}
/** Response of GET health/mcp: the standalone MCP-server reachability probe. The check is a HealthCheck object (backend-project type, serialized by runtime type). */
/** Response of GET health/mcp: the standalone MCP-server reachability probe. */
export interface McpHealthCheckResponse {
timestamp: string;
check: unknown;
check: HealthCheck;
}
/** 429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly quota is exhausted (structurally a success shape: first key is quotaExceeded). */
@@ -2147,14 +2250,14 @@ export interface McpUserEntry {
usagePlan?: string;
}
/** Per-tenant session-status tally envelope shared by MetricsSummary and MetricsSummaryGlobal. Summary items are repository-built rows shaped like MetricsSummaryTenantItem. */
/** Per-tenant session-status tally envelope shared by MetricsSummary and MetricsSummaryGlobal. */
export interface MetricsSummaryResponse {
success: boolean;
summary: Partial<MetricsSummaryTenantItem>[];
summary: MetricsSummaryTenantItem[];
windowDays: number;
}
/** Wire shape of one per-tenant status tally in MetricsSummaryResponse (documentation type — the repository still builds the rows; every key is always present). */
/** One per-tenant status tally in MetricsSummaryResponse. WindowDays is repeated per item (envelope carries it too — historical wire shape, kept for parity). */
export interface MetricsSummaryTenantItem {
tenantId: string;
totalSessions: number;
@@ -2171,10 +2274,10 @@ export interface MetricsSummaryTenantItem {
windowDays: number;
}
/** Shared response of GET global/notifications and GET notifications: the active (non-dismissed) notifications visible to the caller, newest first. Items are GlobalNotificationDto objects (backend-project type, serialized by runtime type). */
/** Shared response of GET global/notifications and GET notifications: the active (non-dismissed) notifications visible to the caller, newest first. */
export interface NotificationListResponse {
success: boolean;
notifications: unknown[];
notifications: GlobalNotificationDto[];
}
/** Outcome of the last NVD stale-cache refresh walk on THIS instance (in-memory). */
@@ -2322,6 +2425,11 @@ export interface PlatformUsageMetrics {
windowDays: number;
}
/** One approved tenant on the wire. Deliberately NOT the storage entity: the pre-2026-08-31 wire carried synthetic PreviewWhitelistEntity rows whose only real datum was the tenant id in partitionKey (plus garbage defaults) — the contract is now just the id. */
export interface PreviewWhitelistTenantEntry {
tenantId: string;
}
/** Response of GET /api/progress/sessions/{sessionId}/events — the session's event stream after the serial knowledge proof passed. */
export interface ProgressGetSessionEventsResponse {
success: boolean;
@@ -2403,6 +2511,40 @@ export interface RebootSpan {
segmentKey: string;
}
/** Run report of the legacy-session reclassification job (POST maintenance/reclassify-legacy). */
export interface ReclassificationResult {
mode: string;
dryRun: boolean;
tenantsExamined: number;
sessionsExamined: number;
wouldChange: number;
changed: number;
toSucceeded: number;
toIncomplete: number;
keptFailed: number;
skipped: number;
errors: number;
/** True when the maxSessions cap stopped the run before the backlog was exhausted — re-run to continue. */
capReached: boolean;
readonly samples: ReclassificationSample[];
}
export interface ReclassificationSample {
tenantId: string;
sessionId: string;
oldStatus: string;
newStatus: string;
reason: string;
}
/** Response of POST maintenance/reclassify-legacy: the reclassification run report plus trigger attribution. See BackfillJobRunResponse for the shared key layout. */
export interface ReclassifyJobRunResponse {
success: boolean;
result: ReclassificationResult;
triggeredBy: string;
triggeredAt: string;
}
/** Structural health report for a session's persisted SignalLog + DecisionTransitions journal. Produced by GET /api/sessions/{id}/reducer-verification (Plan §M5, admin/ops endpoint, not tenant-exposed). Scope: this report covers structural invariants that can be checked without running the reducer — ordinal contiguity, cross-references, ReducerVersion drift, counts. A full engine replay with per-step diff would require polymorphic deserialisation of the DecisionSignal.Evidence payload and is a dedicated follow-up. */
export interface ReducerVerificationReport {
tenantId: string;
@@ -2530,6 +2672,53 @@ export interface RuleCondition {
eventAFilterValue: string;
}
/** Full diagnostic trace of one dry-run evaluation. Serialized camelCase to clients. */
export interface RuleDryRun {
verdict: string;
/** Number of events in the session the rule was evaluated against. */
eventCount: number;
readonly preconditions: RuleDryRunPrecondition[];
readonly conditions: RuleDryRunCondition[];
/** Empty unless all required conditions were met (mirrors the production path, which never reaches factor evaluation otherwise). */
readonly confidenceFactors: RuleDryRunFactor[];
baseConfidence: number;
/** base + matched factor weights, capped at 100. Null when the evaluation ended before the confidence stage (precondition skip / required miss / nothing matched). */
finalConfidence?: number;
confidenceThreshold: number;
/** True only for verdict "fired" AND the rule's effective MarkSessionAsFailed flag. The dry-run itself never touches the session. */
wouldMarkSessionAsFailed: boolean;
/** The evidence map exactly as the production path would persist it on a RuleResult — keys are condition signals (plus factor_* markers). Clients use it to preview {{token}} interpolation of explanation/remediation. Values are heterogeneous evidence objects by design. */
matchedConditions?: Record<string, unknown>;
}
export interface RuleDryRunCondition {
signal: string;
source: string;
eventType?: string;
required: boolean;
matched: boolean;
/** Matched: the evidence dictionary (eventId, timestamp, field, value, …). Not matched: the evaluator's reason string (e.g. "no matching events"). */
evidence?: unknown;
/** How many session events have this condition's eventType at all — the first thing an author checks when a condition unexpectedly doesn't match. Null when the condition has no eventType. */
matchingEventCount?: number;
}
export interface RuleDryRunFactor {
signal: string;
condition: string;
weight: number;
matched: boolean;
}
export interface RuleDryRunPrecondition {
source: string;
eventType: string;
dataField?: string;
operator?: string;
value?: string;
passed: boolean;
}
/** A device-fact gate evaluated before a rule's conditions. Pure boolean filter — does not contribute to evidence or confidence. When any precondition on a rule fails, the rule is silently skipped (no result emitted). Currently only event_data source is supported. */
export interface RulePrecondition {
/** Source of the fact. Currently only event_data. */
@@ -3223,8 +3412,22 @@ export interface TemplateVariable {
/** Response of POST tenants/{tenantId}/admins: the created tenant member. */
export interface TenantAdminCreatedResponse {
/** The stored TenantAdminEntity row (type owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */
admin: unknown;
admin: TenantAdminRow;
}
/** One tenant member (Admin / Operator / Viewer) on the wire — the item of the bare-array GET tenants/{tenantId}/admins and the created member of the POST. Deliberately NOT the storage entity: the ITableEntity keys (partitionKey/rowKey/eTag/timestamp) that the pre-2026-08-31 wire carried are storage internals and were dropped from the contract (no consumer read them). */
export interface TenantAdminRow {
/** Tenant ID (lowercase). */
tenantId: string;
/** User Principal Name (lowercase). */
upn: string;
isEnabled: boolean;
addedDate: string;
addedBy: string;
/** Role: "Admin", "Operator", "Viewer". A null (legacy pre-role row) omits the key and means Admin. */
role?: string;
/** Whether this Operator can manage bootstrap tokens (only relevant for Operator). */
canManageBootstrapTokens: boolean;
}
/** One pre-write config snapshot in GET config/{tenantId}/backups — metadata only, the raw EntityJson (clear-text secrets) is never returned. */
@@ -3241,6 +3444,18 @@ export interface TenantConfigBackupItem {
diff?: Record<string, string>;
}
/** One entry of the tenant-config field schema (built by TenantConfigPatchService.BuildFieldsSchema). Serialized camelCase; null Format/Reason keys are omitted (WhenWritingNull) — the null and set cases are pinned by ConfigWireParityTests. */
export interface TenantConfigFieldSchema {
name: string;
type: string;
format?: string;
nullable: boolean;
writable: boolean;
reason?: string;
gaOnly: boolean;
revertProtected: boolean;
}
/** Success response of PATCH config/{tenantId}/fields and POST config/{tenantId}/revert (both flow through the same outcome writer): which fields changed, the masked diff, and the pre-write backup id. */
export interface TenantConfigPatchOutcomeResponse {
success: boolean;
@@ -3696,8 +3911,7 @@ export interface TriggerEpssSyncResponse {
export interface TriggerMaintenanceResponse {
success: boolean;
message: string;
/** The MaintenanceResult run report (type owned by the Functions project, so this stays object; serialized by runtime type, wire-identical). */
result: unknown;
result: MaintenanceResult;
triggeredBy: string;
triggeredAt: string;
}