Files
AutopilotMonitor/docs/drift-guards.md
T
Oliver Kieselbach eaef1601d0 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>
2026-08-31 15:22:16 +02:00

9.3 KiB
Raw Blame History

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
contracts
guard-tests
signalr
web
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/JsonAsync overloads constrain on IApiResponse — an anonymous object cannot satisfy the constraint. Error bodies (first key error/message, or literal success = false) stay anonymous by design.
  • TypedResponseGuardTests (successor of OkAsyncBaselineGuardTests): both per-file baselines are EMPTY — any anonymous success body, through the helpers or raw WriteAsJsonAsync, is a straight failure. A reflection fact keeps every IApiResponse implementer 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 (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 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 catalog
  • src/Backend/AutopilotMonitor.Functions/Helpers/RowKeyCodec.cs + RowKeyCodecTests.cs
  • src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs — DeletionRowDump.Table, DeletionTombstoneTables
  • src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs + WireTypeManifestBuilder.cs + the *WireParityTests files
  • src/Shared/AutopilotMonitor.Shared/Models/CommonApiModels.cs — IApiResponse, [WireContract], [ProjectedItems]
  • src/Web/autopilot-monitor-web/utils/wire-types.generated.ts (via scripts/generate-shared-manifest-types.js)
  • src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts + __tests__/sectionFieldMap.test.ts
  • src/Web/autopilot-monitor-web/lib/scopedApi.ts, lib/navVisibility.ts, hooks/concreteAdminScopeView.ts
  • Version Contract — the web /version.json stamp + deploy verify added in the same round