P5.x-A: Constants.SignalRMessages catalog (13 names) + SignalRGroupHelper.HubName;
all backend send sites and web subscriptions typed against the shared-manifest
union (SignalRContext.on/off) — unknown names are tsc errors. Dead eventStream
session-payload handling removed from diagnosis page.
P5.x-B: RowKeyCodec as the single inverted-tick encoder (persisted widths pinned,
UserActivity D20 legacy); DeletionRowDump.Table on tombstone rows with
DeletionTombstoneTables.Resolve replacing the duplicated Contains('_') heuristic
(legacy fallback retained for pre-field manifests).
P7.1: PATCH config/{tenantId}/fields flipped to TenantAdminOrGA (phase 2);
Settings sections save via per-section diff PATCH from sectionFieldMap.ts —
the ~39-field stale round-trip vector is gone; legacy webhook clearing now only
on the notifications save; agent-settings reset no longer discards contact edits.
P7.2/7.3: useGlobalAdminScope is a projection over useAggregatedAdminScope
(resolveConcreteScopeView, test-pinned); scopedApi builders own the tenant/global
endpoint-pair routing for all 11 former hand-rolled call sites; scopedFetch adds
the shared JSON fetch layer.
P7.4: navVisibility extraction + roles x nav-config matrix and nav<->route-guard
consistency test with a ratchet for the two known pre-existing mismatches
(Operator and GlobalReader vs /settings — user decision pending).
P7.5: web build stamp public/version.json (prebuild, GITHUB_SHA), SWA no-store
route, deploy-web verify poll + versions/web.json manifest, Portal Build card.
P7.6: OkAsyncBaselineGuardTests freezes the 44 anonymous-response sites
(shrink-only ratchet).
Verified: dotnet build 0W/0E, Functions.Tests 4123, DecisionCore 519, V2.Core
2206, SummaryDialog 44; tsc 0, ESLint 0, vitest 762/762; mutation proofs for all
new drift nets. Docs: docs/drift-guards.md + versioning.md web row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6.0 KiB
type, title, description, resource, tags, timestamp
| type | title | description | resource | tags | timestamp | ||||
|---|---|---|---|---|---|---|---|---|---|
| Concept | Drift Guards — SignalR Names, RowKeys, 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, the OkAsync ratchet, per-section config PATCH, and the web's scoped-endpoint builders. | /src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs |
|
2026-08-13T00: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.
Anonymous OkAsync responses — frozen, shrink-only
OkAsyncBaselineGuardTests freezes the 44 existing req.OkAsync(new { … }) call
sites as a per-file baseline. A new anonymous response (new file or count above
baseline) fails; converting a site to a typed DTO (Shared response models, picked up
by the manifest parity tests) fails too until its baseline entry is lowered — the debt
list stays truthful and only ever shrinks.
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/OkAsyncBaselineGuardTests.cssrc/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