20 KiB
Design: M8 — Transport (T18, T20)
Date: 2026-06-18
Status: Approved (brainstorming session) — ready for implementation planning
Milestone: M8 of docs/plans/2026-06-15-stillpending-completion-design.md (Phase 2 — Expand)
Component: #24 Transport (extends; component count stays 26)
Source items: stillpending.md Transport (#24): T18 (site/instance-scoped artifact transport + name-mapping), T20 (per-line/Myers diff for Modified artifacts); plus Tier-2 defect #16 (stale-instance enumeration stub).
Goal
Extend the file-based, encrypted Transport bundle subsystem — today central-config-only (templates, shared scripts, external systems, central DB connections, notification lists, SMTP configs, API methods) — to also promote site-scoped and instance-scoped artifacts across environments, via a name-mapping subsystem that reconciles source-environment site/connection identities to the target environment. Replace the coarse line-count delta in the Modified-artifact diff with a real per-line Myers diff. Make the stale-instance enumeration real.
Scope (locked decisions)
- D-Scope — Full M8: T18 + T20 + fix #16.
- D1 (T18 granularity) — Sites + individual instances. An operator can select a whole
Site(pulling itsDataConnections + all itsInstances) and/or cherry-pick individualInstances. Instances carry theirInstanceAttributeOverrides,InstanceAlarmOverrides,InstanceNativeAlarmSourceOverrides,InstanceConnectionBindings, and optionalAreamembership (by name). - D2 (name-mapping UX) — Interactive wizard step + CLI flags. Auto-match source→target by
SiteIdentifier/ connectionName; operator overrides to an existing target or "create new". Unmapped references become blocker rows. CLI mirrors via--map-site src=dstand--map-connection site/src=dst. - D3 (environment-specific data) — Carry full config (encrypted secrets). Site
NodeA/B+GrpcNodeA/Baddresses travel; newDataConnectionPrimaryConfiguration/BackupConfiguration(endpoint + credentials) travel inside the encryptedSecretsBlock; operator overwrites target values post-import as needed.
Out of scope (deferred to their own brainstorm, per completion design)
- T19 — direct cluster-to-cluster pull; asymmetric bundle signing; differential/incremental bundles.
Background — current Transport surface (as audited)
- Content container:
BundleContentDto(Transport/Serialization/EntityDtos.cs):TemplateFolders, Templates, SharedScripts, ExternalSystems, DatabaseConnections, NotificationLists, SmtpConfigs, ApiMethods(+ legacy read-onlyApiKeys). Persistence-shaped twinEntityAggregate. - Selection:
ExportSelection(Commons/Types/Transport) carries ID lists per type +IncludeDependencies. Across the wire (CLI/ManagementActor) selection is name-based; resolved to IDs inManagementActor. - Flow:
BundleExporter.ExportAsync→DependencyResolver.ResolveAsync→EntitySerializer.ToBundleContent→ManifestBuilder.Build→BundleSerializer.Pack. Import:BundleImporter.LoadAsync(envelope/zip-bomb guards, manifest+hash validation, decrypt) →PreviewAsync(per-typeArtifactDiff.Compare*+DetectBlockersAsync) →ApplyAsync(single EF transaction; per-typeApply*Async; intermediate flush; second-pass rewireResolveAlarmScriptLinksAsync/ResolveCompositionEdgesAsync;BundleImportedaudit; commit). - Conflict model:
ImportPreviewItem(EntityType, Name, ExistingVersion?, IncomingVersion?, ConflictKind{Identical,Modified,New,Blocker}, FieldDiffJson?, BlockerReason?);ImportResolution(EntityType, Name, ResolutionAction{Add,Overwrite,Skip,Rename}, RenameTo?);ImportResult(BundleImportId, Added, Overwritten, Skipped, Renamed, StaleInstanceIds, AuditEventCorrelation, ApiKeysIgnored). - Diff:
ArtifactDiffemits coarse field changes +<N lines>markers for code (AddCodeChangeIfDifferent). Per-line diff explicitly deferred (T20). #16:ImportResult.StaleInstanceIds = Array.Empty<int>()(BundleImporter.cs ~L736) — known stub.- Plumbing:
TransportCommands.cs(ExportBundleCommand/PreviewBundleCommand/ImportBundleCommand, base64 envelope);ManagementActorhandlers ~L2301–2468; CLIBundleCommands.cs.
Site-scoped entity model (as audited)
Site(Commons/Entities/Sites/Site.cs):Id,Name,SiteIdentifier(unique routing key),Description?,NodeAAddress?,NodeBAddress?,GrpcNodeAAddress?,GrpcNodeBAddress?. RepoISiteRepository(GetSiteByIdentifierAsync,GetAllSitesAsync,GetDataConnectionsBySiteIdAsync, …).DataConnection(Commons/Entities/Sites/DataConnection.cs):Id,SiteId(FK),Name(unique within site),Protocol,PrimaryConfiguration?(JSON: endpoint+creds),BackupConfiguration?,FailoverRetryCount. RepoISiteRepository.Instance(Commons/Entities/Instances/Instance.cs):Id,TemplateId(FK),SiteId(FK),AreaId?,UniqueName(globally unique),State{NotDeployed,Enabled,Disabled}, + collectionsAttributeOverrides,AlarmOverrides,ConnectionBindings,NativeAlarmSourceOverrides. ReposITemplateEngineRepository(CRUD) +IDeploymentManagerRepository.- Overrides/bindings:
InstanceAttributeOverride(Id, InstanceId, AttributeName, OverrideValue?, ElementDataType?).InstanceAlarmOverride(Id, InstanceId, AlarmCanonicalName, TriggerConfigurationOverride?, PriorityLevelOverride?).InstanceNativeAlarmSourceOverride(Id, InstanceId, SourceCanonicalName, ConnectionNameOverride?, SourceReferenceOverride?, ConditionFilterOverride?)— name-based connection ref.InstanceConnectionBinding(Id, InstanceId, AttributeName, DataConnectionId, DataSourceReferenceOverride?)— numeric FK connection ref.
- Staleness:
DeploymentService.GetDeploymentComparisonAsynccomparesDeployedConfigSnapshot.RevisionHashto the freshly-flattened hash. Deployments page surfaces drift; redeploy is the standard flow (Transport never bypasses it). - Audited handlers:
ManagementActorHandleCreate/Update/DeleteSite,…DataConnection,HandleCreateInstance(InstanceService),HandleSetConnectionBindings,HandleSetInstanceOverrides. Site-scope enforced viaEnforceSiteScope/EnforceSiteScopeForInstance.
Architecture
A. Bundle format extension (additive)
Extend the existing format — no second bundle type (rejected: duplicates machinery, splits UX).
BundleContentDtogains additive arrays:Sites: IReadOnlyList<SiteDto>—SiteDto(SiteIdentifier, Name, Description?, NodeAAddress?, NodeBAddress?, GrpcNodeAAddress?, GrpcNodeBAddress?).DataConnections: IReadOnlyList<DataConnectionDto>—DataConnectionDto(SiteIdentifier, Name, Protocol, int FailoverRetryCount, SecretsBlock? Secrets).PrimaryConfiguration/BackupConfiguration(endpoint+creds) rideSecrets(presence-only diff, encrypted-at-rest in the bundle). Named to avoid collision with the existingDatabaseConnectionDto(External-System DB defs).Instances: IReadOnlyList<InstanceDto>—InstanceDto(UniqueName, TemplateName, SiteIdentifier, AreaName?, InstanceState State, AttributeOverrides[], AlarmOverrides[], NativeAlarmSourceOverrides[], ConnectionBindings[]), with child DTOs:InstanceAttributeOverrideDto(AttributeName, OverrideValue?, DataType? ElementDataType).InstanceAlarmOverrideDto(AlarmCanonicalName, TriggerConfigurationOverride?, PriorityLevelOverride?).InstanceNativeAlarmSourceOverrideDto(SourceCanonicalName, ConnectionNameOverride?, SourceReferenceOverride?, ConditionFilterOverride?).InstanceConnectionBindingDto(AttributeName, ConnectionName, DataSourceReferenceOverride?)— note:ConnectionName(resolved from the source FK at export) replaces the numeric FK, so it is portable.
BundleSummary+ manifestcontents[]extended withSites,DataConnections,Instances(counts +dependsOnedges, e.g.Instance:Tank01 dependsOn ["Template:Tank","Site:plant-a","DataConnection:plant-a/opc-main"]).- Versioning:
bundleFormatVersionstays 1;schemaVersion1.0 → 1.1 (additive-only; older importers accept minor increments and list the new entity entries as "skipped — unsupported in this version" per the existing forward-compat rule).
B. Name-mapping subsystem (core of T18)
New Commons type:
BundleNameMap(
IReadOnlyList<SiteMapping> Sites,
IReadOnlyList<ConnectionMapping> Connections)
SiteMapping(string SourceSiteIdentifier, MappingAction Action, string? TargetSiteIdentifier)
ConnectionMapping(string SourceSiteIdentifier, string SourceConnectionName, MappingAction Action, string? TargetConnectionName)
enum MappingAction { MapToExisting, CreateNew }
- Detection (PreviewAsync): after deserialize, collect the distinct source
Sites andDataConnections referenced by the bundle's instances (viaConnectionBindings.ConnectionName,NativeAlarmSourceOverrides.ConnectionNameOverride, andInstance.SiteIdentifier) and the sites/connections carried in the bundle directly. Return them asRequiredSiteMappings/RequiredConnectionMappingson the preview, each pre-populated with an auto-match:MapToExistingwhen a targetSiteIdentifier(or(site,name)connection) already exists; otherwiseCreateNew. - Wizard Map step: renders the required mappings as a table; per row a dropdown
{ <existing target> | Create new }. Connections are grouped under their (mapped) target site. - CLI:
--map-site srcIdentifier=dstIdentifier(repeatable),--map-connection srcSiteIdentifier/srcName=dstName(repeatable); a--create-missing-sites/--create-missing-connectionsconvenience defaults unmatched rows toCreateNew(otherwise unmatched → blocker, preserving fail-safe behaviour for automation). - Blockers: any referenced site/connection that resolves to neither a mapping target nor an in-bundle artifact → blocker row (
BundleImportConnectionUnresolved/ unresolved-site reason). Apply disabled until resolved.
C. Identity vs conflict (two orthogonal axes)
- Identity resolution — name-map for
Site/DataConnection;UniqueNameforInstance;Namefor all existing central types. This decides which target row an artifact corresponds to. - Conflict resolution —
ResolutionAction{Add,Overwrite,Skip,Rename}then applies per artifact exactly as today:MapToExisting+Skip→ leave target untouched.MapToExisting+Overwrite→ apply bundle config to the existing target.CreateNew→Add.Renamefor instances uses a newUniqueName; for sites a newSiteIdentifier(rare).
D. Export flow
ExportSelectiongainsSiteIds: IReadOnlyList<int>+InstanceIds: IReadOnlyList<int>.DependencyResolverexpansion (whenIncludeDependencies):- selected Site → its
DataConnections + all itsInstances; - selected Instance → its
Site, itsDataConnections (referenced by bindings + native-alarm overrides), and itsTemplate(existing template-closure expansion); Instance→Template→ existing shared-script / external-system expansion already covers transitive deps.
- selected Site → its
EntitySerializer.ToBundleContentresolves each instance'sTemplateId→name,SiteId→SiteIdentifier,AreaId→name, and eachConnectionBinding.DataConnectionId→(siteIdentifier, connectionName); carries connection config intoSecrets.- Export wizard Step 1 adds a Sites group (with nested instances) + a flat Instances selector; Step 2 dependency review shows the new edges.
RequireDesignunchanged.
E. Import flow + wizard
- Wizard: Upload → Passphrase → Map (new; shown only when the bundle contains site/instance-scoped artifacts) → Diff & resolve → Confirm → Result.
IBundleImporter.PreviewAsyncreturns the diff plus required mappings.ApplyAsync(sessionId, resolutions, nameMap, user, ct)(signature extended withnameMap) runs in the existing single EF transaction:- Resolve/create target
Sites per the site-map (CreateNew → fullSiteDtoconfig; MapToExisting → use target, honour conflict action). - Resolve/create target
DataConnections per the connection-map (config fromSecrets). - Existing central-config apply (
ApplyTemplatesAsync, …) — unchanged. - Upsert
Instances: resolveTemplateName→target template id (blocker if unresolved),SiteIdentifier→target site id (from map),AreaName→area (create-if-missing within target site),Stateforced toNotDeployedon import; write overrides; writeConnectionBindings withConnectionName→targetDataConnectionId(from map) in the second-pass rewire; rewriteNativeAlarmSourceOverride.ConnectionNameOverrideto the mapped target name. - Semantic validation (existing two-tier) — unchanged.
BundleImportedaudit +BundleImportIdcorrelation (existing) — now also covers the per-entity Site/DataConnection/Instance rows because we route through the audited repository methods.- Commit.
- Resolve/create target
- Reuses audited repository paths so per-entity audit rows +
BundleImportIdcorrelation flow automatically; site-scope (EnforceSiteScope) honoured for each target site at import.RequireAdminunchanged. - Blockers: instance whose
TemplateNameresolves in neither bundle nor target; unresolved connection/site references (see B).
F. T20 — per-line Myers diff
- New pure helper
LineDiffer(Transport project or Commons/Types/Transport) implementing Myers O(ND) LCS over\n-split lines (custom, no third-party library — matches the custom-KpiTrendChartprecedent; central package management discourages new deps). - Output: structured hunks of
{ Op: Context|Add|Remove, Line, OldLineNo?, NewLineNo? }, size-capped byTransportOptions(MaxDiffLinesdefault ~400,MaxDiffHunks); beyond the cap, emit atruncated:truemarker + a final<+N / -M more lines>summary so very large scripts don't bloatFieldDiffJson. ArtifactDiff.AddCodeChangeIfDifferent(and the script-children path) replace the<N lines>marker with the hunk diff embedded inFieldDiffJsonforTemplateScript.Code,SharedScript.Code,ApiMethod.Script(andExternalSystemMethodbody if present). Non-code fields keep coarse value diffs.- Import wizard Modified rows render the
+/-line view (fulfils the spec's Step-3 "shows+/-/~line diff" promise that the implementation never met). bUnit + Playwright coverage for the rendered diff.
G. #16 — real stale-instance enumeration
ImportResult.StaleInstanceIdsbecomes real. InsideApplyAsync, before commit: for every Template Overwritten by the import, enumerate existing deployed target instances of that template and compute hash drift (reuse the flatten +DeployedConfigSnapshot.RevisionHashcomparison the Deployments page already uses; called inside the open transaction against staged changes, or post-flush pre-commit). Return the drifting instance ids.- Directly-imported instances land
NotDeployed→ surfaced as new in the result (not "stale"; stale = deployed-and-drifted). - Step 4 "N instances will become stale" shows the real count; Step 5's existing Deployments deep-link gets real ids. UI already wired — this fills it with data.
Error handling (additions to the existing table)
| Where | Failure | Surfaced as |
|---|---|---|
| Map | Source site referenced, no auto-match, not mapped, CreateNew not chosen |
Blocker row: "Site '{id}' not mapped to a target" |
| Map | Connection referenced by an instance binding, unmapped & not in bundle | Blocker row (BundleImportConnectionUnresolved) |
| Apply | Instance's TemplateName resolves nowhere |
Blocker row before Apply enabled |
| Apply | Create-new site/connection but config absent (unencrypted bundle stripped) | Created with available fields; warning event; operator completes config post-import |
All-or-nothing per bundle (unchanged): the EF transaction wraps site/connection/instance writes + audit; rollback removes everything.
Security
- DataConnection endpoints + credentials ride the encrypted
SecretsBlock; unencrypted export still tripsUnencryptedBundleExport. Diffs are presence-only for secrets (never echo endpoints/creds). - Import remains
RequireAdmin; site-scope enforced per target site. - No new HTTP channel; bundle bytes ride the existing base64
/managementenvelope (already raised to 200 MB).
Authorization (unchanged)
| Operation | Role |
|---|---|
Export page / bundle export (incl. sites/instances) |
RequireDesign |
Import page / bundle preview+import (incl. sites/instances) |
RequireAdmin |
Audit
- Reuse existing per-entity audited repository methods for Site/DataConnection/Instance create/update so rows carry
BundleImportId. - New warning events (mirroring existing
BundleImportAlarmScriptUnresolved/…CompositionUnresolved):BundleImportConnectionUnresolved,BundleImportSiteUnresolved(when a non-blocking soft-reference can't bind).
Testing strategy
- Unit:
LineDiffer(Myers correctness + size-cap + edge cases: empty, identical, all-add, all-remove, CRLF);BundleNameMapauto-match + CLI flag parsing;EntitySerializerround-trip forSiteDto/DataConnectionDto/InstanceDto(incl. connection-name resolution);ArtifactDiffSite/Connection/Instance compare. - Integration: export a site (+connections+instances) → import to a fresh target with a name-map (create-new) and to a populated target (map-to-existing + Overwrite/Skip); FK remapping of
ConnectionBinding/NativeAlarmSourceOverride; blocker on unresolved template/connection;#16stale enumeration (overwrite a template that has deployed instances → assert returned ids drift); rollback leaves nothing partial. - bUnit: Map step rendering + auto-match; Modified row
+/-diff render. - Playwright: export-with-site → import-with-mapping happy path; diff line view.
- Targeted builds/tests per task; full-solution build +
bash docker/deploy.shrebuild + live smoke at integration.
Forward compatibility
- Older importers (schemaVersion 1.0) reading a 1.1 bundle: unknown
Site/DataConnection/Instanceentries listed as "skipped — unsupported in this version" (existing rule). Central-config artifacts still import. bundleFormatVersionunchanged (1) — no hard refusal.
Execution plan (waves — for writing-plans)
- Wave A (format + name-map foundation): Commons DTOs/types (
SiteDto/DataConnectionDto/InstanceDto+ children,BundleNameMap, extendedExportSelection/BundleSummary/ManifestContentEntryusage, extendedImportResult/preview required-mappings),LineDifferpure helper. (high-risk: data contracts; standard: LineDiffer) - Wave B (export):
DependencyResolversite/instance expansion;EntitySerializersite/connection/instance mapping (FK→name);ManifestBuilder/summary counts;ExportSelectionwiring +ManagementActor.HandleExportBundlename resolution; CLIbundle exportsite/instance flags. (high-risk) - Wave C (diff + preview):
ArtifactDiffMyers integration (T20) + Site/Connection/Instance compare;BundleImporter.PreviewAsyncrequired-mapping detection + blocker scan extension. (standard/high-risk) - Wave D (apply + name-map + #16):
BundleImporter.ApplyAsyncnameMapparam + site/connection resolve-or-create + instance upsert + FK rewire;#16stale enumeration;ManagementActor.HandleImportBundle/PreviewBundleplumbing; CLIbundle import--map-*flags. (high-risk; serial reviews) - Wave E (UI): Export wizard Sites/Instances selection; Import wizard Map step + Modified
+/-diff render. (standard) - Integration: docs (Component-Transport.md, Component-CLI.md, README, stillpending.md, completion-design.md), full build, docker rebuild, Playwright, live smoke, end-to-end cross-cluster trace.
Open items / risks
- The name-map detection must be exhaustive: every place a transported instance points at a connection (
ConnectionBinding,NativeAlarmSourceOverride) and at a site must be covered, or an import silently drops a binding. Integration tests assert FK remap explicitly. #16hash-drift computation inside the open transaction must use the post-staged template state; verify it doesn't double-count directly-imported (NotDeployed) instances.Areacreate-if-missing is the lightest reasonable choice; if areas grow fields later, revisit.
Next step
Hand off to writing-plans to produce the bite-sized, task-metadata'd implementation plan + .tasks.json, then execute subagent-driven in this worktree.