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>
9.3 KiB
type, title, description, resource, tags, timestamp
| type | title | description | resource | tags | timestamp | ||||
|---|---|---|---|---|---|---|---|---|---|
| Concept | Drift Guards — SignalR Names, RowKeys, Wire DTOs, Config Sections, Scoped Routing | The 2026-08 fragility-audit follow-up contracts that turn formerly hand-synchronized mirrors into enforced ones — SignalR message-name catalog, the inverted-tick RowKey codec, tombstone table tagging, typed wire DTOs with generated TypeScript interfaces, per-section config PATCH, and the web's scoped-endpoint builders. | /src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs |
|
2026-08-31T00:00:00+02:00 |
Drift Guards
Follow-up to the 2026-08 fragility audit (see Lifecycle Manifests & Session Scope Resolution for the earlier rounds). Each section below replaces a hand-synchronized mirror with an enforced contract: a change on one side now fails a build, a type check, or a test on the other side.
Schema
SignalR message names — one catalog, typed on both ends
Constants.SignalRMessages (Shared) is the complete catalog of SignalR target names.
Backend senders (output bindings and the imperative SignalRNotificationService)
reference the constants; the hub name is SignalRGroupHelper.HubName. The catalog is
exported into shared-manifests.json (signalRMessages section) by
SharedManifestParityTests, and the web's SignalRContext.on/off type their event
name against the generated union (lib/signalrMessages.ts) — subscribing to a name
the backend never sends is a tsc error, not a silent no-op. newevents is the one
legacy lowercase name; it is a persisted wire name, do not "fix" the casing.
Inverted-tick RowKeys — one codec
RowKeyCodec (Functions/Helpers) is the single encoder/decoder for
rev(t) = MaxTicks - t.Ticks RowKeys. Every writer routes through it; the persisted
per-table shapes (D19 standard, AuditLogs !-prefix, UserActivity's legacy D20 width,
ConfigurationBackups' 28-char truncation) are pinned by RowKeyCodecTests and must
never change — a width/prefix change reorders live tables against their existing rows.
BusinessTimestamp keeps the OData clause builders and delegates decode to the codec.
Tombstone rows carry their table
DeletionRowDump.Table names the source table on FINAL (Tombstone) rows — the one
step that mixes two tables while DeletionStep.Table is null.
DeletionTombstoneTables.Resolve is the only consumer-side resolution (delete +
restore + dry-run); the historical Contains('_') RowKey-shape heuristic survives
solely as the fallback for manifests written before the field existed and must outlive
one full manifest-retention cycle.
Wire DTOs — generated interfaces, zero anonymous success bodies
Every HTTP success body is a flat class in AutopilotMonitor.Shared.Models
implementing the IApiResponse marker (2026-08-31, feat/typed-api-contract —
migrated from the 44 OkAsync(new { … }) + 134 raw WriteAsJsonAsync(new { … })
sites the 2026-08-13 audit froze). Three layers enforce it:
- Compile time: the only
OkAsync/CreatedAsync/JsonAsyncoverloads constrain onIApiResponse— an anonymous object cannot satisfy the constraint. Error bodies (first keyerror/message, or literalsuccess = false) stay anonymous by design. TypedResponseGuardTests(successor ofOkAsyncBaselineGuardTests): both per-file baselines are EMPTY — any anonymous success body, through the helpers or rawWriteAsJsonAsync, is a straight failure. A reflection fact keeps everyIApiResponseimplementer flat (System.Text.Json serializes derived properties before base ones — key order is wire contract; MCP hands raw JSON to an LLM) and inside the Shared assembly.*WireParityTests: each migrated site carries an ordinal old-anonymous-literal vs. new-DTO serialization proof (productionApiJsonOptions), 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-objectproperty outsideObjectSlotBaseline(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
invent a key); preformatted strings (.ToString("o"), enum .ToString()) stay
string; fields= projections stay IReadOnlyList<object> tagged
[ProjectedItems(typeof(Item))]; raw table rows stay verbatim-keyed dictionaries
(no DictionaryKeyPolicy — dictionary KEYS never run through the camelCase policy,
and dictionary VALUES keep explicit nulls, both pinned by tests).
SharedManifestParityTests (schemaVersion 2) reflects the full graph — every
IApiResponse implementer plus [WireContract]-marked payload types, transitively
closed by WireTypeManifestBuilder — into the types section of
shared-manifests.json, carrying C# <summary> texts. The web codegen
(npm run generate:manifests) emits utils/wire-types.generated.ts (one interface
per object, one string union per enum, JSDoc from the summaries); the hand-written
types/session.ts / types/enrollment.ts / types/adminConfig.ts re-export from
it. Freshness is pinned by the vitest suite and the shared-manifests-in-sync CI
job. Unmappable shapes (non-string dictionary keys, foreign BCL classes) fail the
manifest build instead of degrading to unknown.
Tenant settings — per-section PATCH
The web's Settings sections no longer PUT the full ~92-field configuration. Each
section's exact write surface lives in app/settings/sectionFieldMap.ts (owned fields
plus documented alsoWrites write-throughs); saveConfiguration diffs those fields
against the loaded config and PATCHes only the changes through
PATCH config/{tenantId}/fields — the transactional endpoint (CAS + fail-closed
backup + exactly-these-fields verify + auto-rollback) that previously served MCP only.
The policy line is TenantAdminOrGA; a tenant admin runs on the stricter
TenantConfigCallerTier.TenantAdmin whose deny-list turns GA-only fields into
explicit 400s. Deploy order: backend before web. sectionFieldMap.test.ts pins
field↔section ownership (each field exactly one owner, all fields on the model, none
server-denied); backups/revert stay GlobalAdminOnly.
Scoped endpoint routing — one decision object
lib/scopedApi.ts owns the tenant/global endpoint-pair choice: pages pass their scope
hook object (routeGlobal, selectedTenantId, effectiveTenantId) instead of
hand-rolling routeGlobal ? global(..., sel || undefined) : tenant(tid, ...) with the
tenant parameter at a different position per pair. useGlobalAdminScope is now a thin
projection over useAggregatedAdminScope (one hook, aggregated as a mode) via the
pure resolveConcreteScopeView. lib/scopedFetch.ts adds the shared JSON+ok-check
fetch layer. lib/navVisibility.ts extracts the sidebar's roles × nav-config × guard
logic; its matrix test also ratchets the known "sidebar shows it, guard bounces it"
mismatches (plain Operator and GlobalReader vs. /settings — pending a user decision).
Examples
Renaming a SignalR message end to end: change the constant in
Constants.SignalRMessages → AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests → node scripts/generate-shared-manifest-types.js → tsc
now flags every stale web subscription literal.
Adding a field to a Settings section: add it to the section in sectionFieldMap.ts
(tsc validates the name against the manifest), wire the form state into the
updatedConfig construction in TenantConfigContext, and extend the expected-fields
list in sectionFieldMap.test.ts.
Citations
src/Shared/AutopilotMonitor.Shared/Constants.cs— SignalRMessages catalogsrc/Backend/AutopilotMonitor.Functions/Helpers/RowKeyCodec.cs+RowKeyCodecTests.cssrc/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs—DeletionRowDump.Table,DeletionTombstoneTablessrc/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs+WireTypeManifestBuilder.cs+ the*WireParityTestsfilessrc/Shared/AutopilotMonitor.Shared/Models/CommonApiModels.cs—IApiResponse,[WireContract],[ProjectedItems]src/Web/autopilot-monitor-web/utils/wire-types.generated.ts(viascripts/generate-shared-manifest-types.js)src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts+__tests__/sectionFieldMap.test.tssrc/Web/autopilot-monitor-web/lib/scopedApi.ts,lib/navVisibility.ts,hooks/concreteAdminScopeView.ts- Version Contract — the web
/version.jsonstamp + deploy verify added in the same round