diff --git a/docs/drift-guards.md b/docs/drift-guards.md
index f2c502da..a5afe666 100644
--- a/docs/drift-guards.md
+++ b/docs/drift-guards.md
@@ -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
diff --git a/docs/log.md b/docs/log.md
index d85a1a8d..d8a2f6dd 100644
--- a/docs/log.md
+++ b/docs/log.md
@@ -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# `` 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.
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AdminWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AdminWireParityTests.cs
index a127ffdf..daf0b581 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/AdminWireParityTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/AdminWireParityTests.cs
@@ -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 -----------------------------------------------------------
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs
index 0548a6c0..98d82956 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs
@@ -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);
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs
index 3f300c77..ea4c2bc1 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionTests.cs
@@ -471,7 +471,8 @@ public class AuthFunctionTests
// -------------------------------------------------------------------------
///
- /// 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.
///
private static dynamic ToDynamic(object obj)
{
@@ -479,7 +480,7 @@ public class AuthFunctionTests
var dict = new System.Dynamic.ExpandoObject() as IDictionary;
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;
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs
index 513374c9..19515bb4 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs
@@ -224,23 +224,23 @@ public class ConfigWireParityTests
IReadOnlyList schema = new List
{
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(
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs
index 15883caf..c232613b 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs
@@ -16,6 +16,107 @@ namespace AutopilotMonitor.Functions.Tests;
///
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(),
+ 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(),
+ 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 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
+ var payload = new Dictionary
{
- 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
+ {
+ ["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
+ {
+ ["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
+ {
+ 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 ------------------------------------------------
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs
index 82a16ff2..f3fda112 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs
@@ -560,8 +560,9 @@ public class MetricsWireParityTests
public void MetricsSummaryResponse_matches_the_tally_shape()
{
var days = 30;
- // GetMetricsSummaryAsync returns List