* feat(backend,web): Telegram als Channel-Provider + ein einziger Dispatch-Pfad Notifications hatten zwei Sendewege: WebhookNotificationService.SendToChannelsAsync fuer tenant-konfigurierte Channels, und TelegramNotificationService direkt aus dem Ops-Alert-Pfad. Ein neuer Provider haette beide anfassen muessen. Neu: NotificationChannelDispatcher ist DIE Channel-Sende-API und routet pro Provider — WebhookProviderType.Telegram zum Bot, alles andere als gerenderten Webhook-POST. SendToChannelsAsync wurde aus WebhookNotificationService entfernt statt dupliziert, damit es keinen zweiten Pfad gibt; alle sieben Call-Sites ziehen um (EventIngestProcessor, RegisterSession, ReportDistress, SlaBreachEvaluation, AnalyzeOnEnrollmentEnd, TestWebhookNotification). SendOpsAlertAsync/SendNotificationAsync sind virtual (Test-Seam), TelegramNotificationService bekommt SendAlertWithResultAsync fuer die Test-Endpoints und RenderAlertText als testbaren Kern. Telegram (=40) ist der Sonderfall: kein Webhook. Der Channel-Url traegt die CHAT-ID, der Bot-Token gehoert der Plattform (PreviewConfig). Ein Tenant-Admin wuerde durch UNSEREN Bot senden — deshalb ist das Gate eine Server-Regel, keine versteckte Dropdown-Option: ValidateTelegramChannelGate sitzt in TenantConfigValidation.ValidateModel und deckt damit BEIDE Schreibpfade ab (Full-Model-PUT und den MCP-Field-Patch ueber TenantConfigPatchService) — in der Update-Function allein waere der Patch-Pfad daran vorbeigelaufen. Ein unveraenderter, GA-angelegter Channel passiert das Gate, damit ein Tenant-Admin unrelated Config weiter speichern kann. Telegram-Ziele ueberspringen SsrfGuard.ValidateWebhookUrlFormat (eine Chat-ID ist keine URL und wuerde immer failen) und werden stattdessen als numerische Id oder @username geprueft. Mitgeliefert, weil der Dispatcher und die Ops-Seite sie brauchen: NotificationChannel.RedactList / RestoreRedactedList als geteilte Helfer hochgezogen (TenantConfiguration delegiert jetzt dorthin, statt eine zweite Kopie zu pflegen), AdminConfiguration.OpsNotificationChannelsJson + GetOpsNotificationChannels() mit Legacy-Slot-Synthese, OpsAlertRule.NotifyChannelIds/IncludePayload, NotificationAlert.DataJson (nur GenericJsonRenderer serialisiert es, additiv in schemaVersion 1.0). Web: der Channel-Editor liegt jetzt in components/notifications/ChannelEditor.tsx und wird von Tenant- und Admin-Seite geteilt (showEventToggles trennt die Faelle). Telegram erscheint im Provider-Dropdown nur fuer Global Admins (Prop aus useAuth().user?.isGlobalAdmin, Hausmuster aus SectionAutopilotValidation); ein bereits konfigurierter Telegram-Channel bleibt fuer alle sichtbar, damit ein Speichern ihn nicht still verwirft. Tests: TelegramChannelProviderTests (27) — Dispatcher-Routing inkl. "Telegram erreicht NIE den SSRF-Pfad", Chat-ID-Validierung, GA-Gate auf beiden Schreibpfaden. Mutationsgeprueft: Gate invertiert => 5 rot, zurueck => 27 gruen. OpsNotificationChannelsTests + OpsAlertPayloadTests decken die mitgelieferten Modell-Additionen ab. TestNotifications ist der gemeinsame inerte Stub, der die 17 bestehenden OpsAlertDispatchService-Konstruktionen ersetzt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(backend,web): Ops-Alerts routen auf benannte Channels statt auf drei feste Slots OpsAlertRule hatte kein Ziel. Jede matchende Regel ging an alle drei fest verdrahteten Slots in AdminConfiguration (Telegram-ChatId / Teams-URL / Slack-URL), und nur die ERSTE matchende Regel wurde ueberhaupt ausgewertet (FirstOrDefault). Ein Conversion-Signal an ein Sales-Team zu binden hiess also: das Sales-Team bekommt auch jede Security-Regel. Jetzt: AdminConfiguration.OpsNotificationChannelsJson haelt eine benannte Channel-Liste (dasselbe NotificationChannel-Modell wie tenant-seitig, nur anderer Scope), und OpsAlertRule.NotifyChannelIds bindet Regeln daran. Dispatch wertet ALLE matchenden Regeln aus (Where statt FirstOrDefault) und vereinigt ihre Ziele. Rueckwaertskompatibel ohne Migrationsjob: - Leere Channel-Liste => GetOpsNotificationChannels() synthetisiert je konfiguriertem Legacy-Slot einen Channel mit stabiler Id (legacy-telegram heisst "Autopilot Monitor IT Engineer") und uebernimmt die Legacy-Enabled-Flags — ein bewusst abgeschalteter Provider bleibt abgeschaltet. - GetAdminConfigurationFunction projiziert DIESELBE Synthese in das Read-Model, damit der Editor zeigt, was heute tatsaechlich Alerts empfaengt, und der erste Save exakt das materialisiert. Die Alternative waere eine zweite Kopie der Synthese-Regeln im Web gewesen, die vom Dispatcher wegdriftet. - Leere NotifyChannelIds = alle aktivierten Channels. Das ist, was jede heute existierende Regel traegt, also verhaelt sich am Deploy-Tag nichts anders. - Ids, die nicht mehr aufloesen, werden verworfen statt auf Broadcast zurueckzufallen: eine Regel, deren einziger Channel geloescht wurde, muss NICHTS erreichen, nicht alles. Die Legacy-Felder bleiben als Migrationsquelle stehen und werden von der Alerts-Section nicht mehr geschrieben — aufraeumen erst, wenn produktiv Channels stehen. Sicherheit: OpsNotificationChannelsJson ist secret-tragend und steht in der Deny-List von RedactedCopyForReader() (ConfigRedactionTests bewacht Drift). Der Admin-PUT hat bisher keinen Redacted-Restore — er bekommt jetzt einen fuer die Channel-Liste, damit ein zurueckgespieltes Reader-View nicht "***REDACTED***" als Chat-ID persistiert, plus die strukturelle Validierung, die auch die Tenant-Liste passiert. Neuer Endpoint POST /api/global/config/test-ops-channel (GlobalAdminOnly, im Policy-Catalog registriert). Kein neues Response-DTO: TestWebhookNotificationResponse hat identische Semantik und wird geteilt — nur der Doc-Kommentar nennt jetzt beide Routen. Web: die "Alert Providers"-Karte weicht der Channel-Liste (geteilter ChannelEditor, im Admin-Kontext mit Telegram und ohne die tenant-spezifischen Event-Toggles). Jede Regel bekommt einen Channel-Picker; leere Auswahl rendert als "all channels", nicht als Warnung — hier ist das der gueltige Default. Die Bindungslogik liegt testbar in opsChannelRouting.ts und spiegelt ResolveTargets: der erste explizite Pick startet vom LEEREN Set, sonst liesse sich eine Broadcast-Regel nie verengen. handleSaveOpsAlertConfig nimmt jetzt ein Objekt statt elf positionaler Argumente — bei einem Dutzend Feldern schreibt ein vertauschtes Paar sonst still eine Webhook-URL in den falschen Provider-Slot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(backend): TenantTrialStarted + Event-Payload optional pro Regel Der Trial-Start war stumm. POST config/{tenantId}/trial schrieb nur einen Audit-Entry, und der GA-Pfad (PATCH plan) feuerte nur beim Downgrade — also war genau der Conversion-Moment, den Sales/Support interessiert, das einzige Plan-Ereignis ohne Ops-Event. RecordTenantTrialStartedAsync (Tenant/Info) feuert jetzt aus BEIDEN Schreibpfaden, unterschieden per selfService-Flag, und jeweils NACH dem persistierten Save (SaveConfigurationAsync ist fail-loud), damit kein Alert eine Trial ankuendigt, die nicht gespeichert wurde. Der GA-Pfad haengt an der aufgezeichneten Aenderung, nicht am Request: ein reiner planTier-Edit bleibt still, ein idempotenter Save mit demselben Datum ebenfalls, und das Beenden einer Trial (explizit null) ist ein Downgrade und bleibt bei TenantPlanDowngraded. Payload-Zustellung: WriteAsync reicht die serialisierten Details an DispatchAsync durch — aber ein Channel bekommt sie nur, wenn eine Regel OpsAlertRule.IncludePayload setzt. Default AUS, bewusst: ein Ops-Alert ist ein "da ist was, schau nach"-Signal, die meisten Payloads sind im Chat Rauschen, und manche tragen Felder, die die Baseline nie traegt (Tenant-Domain, Contact-Adresse). Das Informationsniveau aller bestehenden Regeln bleibt damit unveraendert. ResolveTargets liefert deshalb zwei Gruppen. Ein Channel, den sowohl eine Payload-Regel als auch eine schlichte Regel adressiert, landet nur in der Payload-Gruppe: eine Nachricht pro Channel, und ein unbeteiligtes Geschwister-Match kann ein bewusstes Opt-in nicht aushebeln. Fuer den angereicherten Alert flacht BuildAlert die Top-Level-Skalare in Facts ab (max 12, Werte <= 256 Zeichen, Nulls und Verschachteltes raus), damit Card-Formate und der Telegram-Klartext dieselbe Information tragen wie der GenericJson-Consumer unter "data". EventType wird immer gesetzt — derselbe Wert steckt schon im "Event"-Fact, also keine zusaetzliche Information, nur ein stabiler Schluessel zum Verzweigen. Tests: TenantTrialStartedEventTests (8) — aufgezeichnete Event-Form inkl. Payload-Feldern, und die Emissionsbedingung des GA-Pfads gegen ApplyPlanChanges gepinnt (greift bei Grant und Verlaengerung, schweigt bei Trial-Ende, planTier-only und idempotentem Save). Die HTTP-Entry-Points bleiben ungetestet — Suite-Konvention ist, die reinen Kerne zu testen. Docs: OKF-Concept backend/ops-notification-channels.md. Registrierung in docs/index.md + docs/log.md fehlt bewusst — beide Dateien tragen noch fremdes uncommittetes WIP (ops-events Query-Surface), und ein Teil-Commit darin haette es mitgenommen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(backend,mcp): ops-events server-side field filters + generated MCP tool vocabularies The ops-events read could only narrow by category and date window, so answering "did any AgentEmergencyBreak fire yesterday?" meant pulling a whole category and filtering it client-side — and for the MCP tool that client is a language model, so the question cost a category-sized response in model context. Ops-events filter surface (mirrors the audit-log filter pattern one-for-one): - OpsEventQueryFilters (Shared) + OpsEventFilterRequest (query -> filters + pagination extras) + TableOpsEventRepository.AppendFieldFilters (filters -> OData clauses). BOTH filter builders call the appender — the single-category / unpaged one and the per-partition fan-out builder that adds the RowKey bound — so "no category named" cannot silently return unfiltered rows. - minSeverity expands to an OR-set, not a range: Table Storage compares strings lexicographically and the severity names sort Critical < Error < Info < Warning, so `Severity ge 'Warning'` would drop exactly the alert-worthy half. The ladder now lives in OpsEventSeverity.Rank, shared with OpsAlertDispatchService, so "Warning and above" in a read means what it meant when the alert rules fired. - severity is normalised onto the canonical vocabulary and an unknown value is a 400: `eq` is case-sensitive, so "?severity=warning" would otherwise return an empty page that reads exactly like "no such events happened". eventType stays verbatim — stored rows outlive the vocabulary, so a retired type must remain searchable in the historical window. - Every filter value is folded into the continuation fingerprint and echoed on nextLink; extras are appended AFTER the pre-existing category/tenantId discriminators and skipped when empty, so tokens minted before the filters existed still fingerprint identically. The tenantId drill deliberately stays in memory (OrdinalIgnoreCase; an OData eq would lose rows on casing). - MCP get_ops_events gains eventType / severity / minSeverity plus a `days` shorthand resolved client-side to a concrete dateFrom (a backend-side "last N days" would re-resolve "now" on every page and blow the token fingerprint); an explicit bound always wins over the shorthand. Fixed alongside: the paged cross-category fan-out skipped Platform (Azure Monitor alerts) because its category list had drifted from OpsEventCategory — written from day one, invisible to every paged reader, still visible on the unpaged full-table path. The list is now OpsEventCategory.All plus a reflection coverage test. That drift had a structural cause, so the same round closes it for the MCP as a whole: wire TYPES reached the MCP from day one, but vocabularies did not — types are erased at runtime and z.enum() needs values, so every tool schema hand-typed its own copy with nothing to compare against. The copies had drifted: search_sessions offered 5 of 8 session statuses (no AwaitingUser, no Incomplete — both real stored statuses), both event readers offered 4 of 6 severities (no Debug, no Trace), get_ops_events named 6 of 7 ops categories. - The codegen now emits src/generated/wire-vocabularies.generated.ts (VALUES: session statuses, event severities, ops categories/severities/types, annotation lanes/verdicts) from the manifest sections listed in MCP_VOCABULARIES; SharedManifestParityTests exports the two new ops vocabularies. Freshness is pinned twice — byte-equality against a fresh codegen run in the MCP suite, and the shared-manifests-in-sync CI job. - Tool schemas derive their z.enum() from it and descriptions interpolate the list instead of spelling it. - vocabulary-drift.guard.test.ts ratchets what is left: every inline z.enum([..]) must sit on a baseline naming why it has no backend owner (MCP-local knobs, plus rule type / connection type / CVE risk, which have no C# constants class yet). A new hand-typed list fails, and so does a stale baseline entry. - OpsEventTypes (Shared) gives the 77 ops event types a canonical home: the OpsEventService write sites use the constants (a raw literal now fails OpsEventTypeDualRegisterTests, which moved from regex-scraping to reflection), the manifest exports them, and get_resource(name="ops_event_types") serves categories + severities + types to the model from the generated copy. Suites: Backend 5427, MCP 629 + tsc, Web tsc + vitest 965, generator idempotent. Deploy order: backend BEFORE the MCP redeploy — an MCP that sends filters to an older backend gets silently unfiltered rows back and presents them as filtered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: register the ops notification channels doc in the index and log The concept doc landed with the feature commits; its index entry and log entry were still uncommitted in the working tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 KiB
type, title, description, resource, tags, timestamp
| type | title | description | resource | tags | timestamp | ||||
|---|---|---|---|---|---|---|---|---|---|
| Concept | Drift Guards — SignalR Names, RowKeys, Wire DTOs, Tool Vocabularies, 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, the MCP's generated tool vocabularies with their inline-enum ratchet, 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.
Tool vocabularies — the MCP derives them, it no longer retypes them
Wire TYPES reached the MCP from day one (src/generated/wire-types.generated.ts, the
same codegen output the web gets). Vocabularies did not: types are erased at runtime and
a z.enum([...]) needs values, so every MCP tool schema hand-typed its own copy of
statuses, severities and categories — with nothing to compare against. The copies drifted
exactly as you would expect: search_sessions offered 5 of 8 session statuses (no
AwaitingUser, no Incomplete), both event readers offered 4 of 6 severities (no Debug,
no Trace), and get_ops_events named 6 ops categories while the backend wrote 7. None of
that surfaces as an error — the tool just quietly cannot ask for the missing value.
The vocabularies now ship as VALUES. MCP_VOCABULARIES in the codegen maps manifest
sections onto src/generated/wire-vocabularies.generated.ts (export const SESSION_STATUSES = [...] as const plus its union type); the tool schemas do
z.enum(SESSION_STATUSES) and descriptions interpolate the list instead of spelling it.
Freshness is pinned twice: wire-types-freshness.test.ts compares the committed file
byte-for-byte against a fresh codegen run, and the shared-manifests-in-sync CI job
diffs it after regenerating.
vocabulary-drift.guard.test.ts is the ratchet that keeps it that way: every inline
z.enum([...]) left in src/tools/*.ts must be on a baseline that names why it has no
backend owner (MCP-local knobs like depth: fast|deep, plus three free-string columns —
rule type, connection type, CVE risk — that have no C# constants class yet). A new
hand-typed list fails until it is derived or reviewed onto the baseline, and a stale
baseline entry fails too.
Ops event types got the missing owner: they were bare literals at the OpsEventService
call sites, so nothing could enumerate them and both the portal alert-rule catalog and the
MCP had to retype the list. OpsEventTypes (Shared) now declares all 77; the write sites
use the constants (a raw literal fails OpsEventTypeDualRegisterTests), the manifest
exports them, and get_resource(name="ops_event_types") serves categories + severities +
types to the model from the generated copy.
Backend-internal lists follow the same rule: a list that enumerates a constants class IS
the class. TableOpsEventRepository.AllCategories is OpsEventCategory.All rather than a
retyped copy — the copy had gone stale and silently hid every Platform (Azure Monitor)
event from the paged cross-category read while the unpaged path still showed it.
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/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js—MCP_VOCABULARIES, the vocabulary codegensrc/McpServer/autopilot-monitor-mcp/src/__tests__/vocabulary-drift.guard.test.ts— the inline-enum ratchetsrc/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs+OpsEventTypeDualRegisterTests.cssrc/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