Row 65 — trigger FIRED, fixed. BundleUnlockAttempt rows were pruned only by
nodes actively serving unlocks, so a cluster that stopped importing entirely
left its last rows in place forever. The row's own trigger was "Transport gains
a central scheduler a prune tick could ride" — it has one now:
BundleSessionEvictionService, the central-only 1-minute session sweep added by
the transport-session fixes. It now also ticks the new
IBundleUnlockAttemptWindow.PruneAsync. Both drivers share the ONE 15-minute CAS
cadence claim, so ticking on a 1-minute sweep costs at most one bounded DELETE
per quarter hour and the two can never prune concurrently. PruneAsync is
contractually non-throwing (unlike TryRegisterAttemptAsync, which throws and
lets the limiter own the fail-open decision) — a background sweep has no caller
to report to and must survive a database blip. Both Transport docs updated;
three tests in FleetWideUnlockThrottleTests, incl. the retention sweep running
with no unlock traffic anywhere in the test, which is the whole point.
Row 58 — closed with a pin, and the OLD pin was found blind. The existing
Deployments_Dispose_UnsubscribesFromNotifier is behavioural: it is satisfied by
the _disposed guard alone, so deleting the `-=` from Dispose leaves it green
while the singleton notifier keeps a delegate pinning the circuit-scoped
component. Verified by mutation — with the `-=` removed the old test still
passed and only the two new pins failed. Added:
* a structural retention pin asserting the notifier's invocation list actually
shrinks to 0 on Dispose;
* a source scan pinning the row's stated TRIGGER ("a second page subscribes,
or any subscriber lands without a matching Dispose unsubscribe") — every
CentralUI file that subscribes must also unsubscribe and be IDisposable,
with a non-empty guard so the scan cannot pass vacuously.
Row 60 — closed with a pin, and the register's premise corrected.
GlobalArtifactSnapshot does NOT hold EF-materialised entities: it is projected
into pure Commons DTOs before the fan-out. The EF state that genuinely crosses
into the parallel send is the tracked Site entities the lambda closes over, and
that is safe only while lazy loading is off. Pinned in ConfigurationDatabase
.Tests two ways — no proxies extension on the built DbContextOptions, and no
EntityFrameworkCore.Proxies package reference anywhere in the repo (the
structurally stronger half: UseLazyLoadingProxies() is not a compilable call
without it). The real invariant is now spelled out at the fan-out itself.
Row 59 — no product change; added the missing tripwire remark stating the
constraint the safety argument rests on (serial by design, one scoped DbContext,
do not Task.WhenAll; if it gets slow the fix is a scope PER SITE, not a shared
scope under fan-out). Line numbers in the register have drifted to :528-545.
Rows 36, 37, 64: verification only, no change — see the sweep report.
54 KiB
Component: Transport
Purpose
The Transport component provides a file-based, encrypted, environment-agnostic way to promote configuration artifacts from one ScadaBridge cluster to another through the Central UI. A user with the Design role on the source cluster exports a selected set of templates and supporting artifacts to a .scadabundle file. A user with the Admin role on the target cluster uploads the bundle, reviews a diff, resolves conflicts per artifact, and applies it. Import is config-only: it updates the central configuration database; affected instances surface as stale on the existing Deployments page and the user redeploys via the standard flow. Transport does not touch site runtime nodes and does not move runtime state.
As of M8 (T18), Transport is no longer limited to central-only configuration: it also moves site-scoped configuration — Site definitions, site-scoped DataConnections (protocol connections), and Instances with their override children and area membership. Because site identifiers and connection names differ across environments, a name-mapping subsystem (see "Name Mapping") reconciles source references onto target sites/connections at import time. This is still purely a central-configuration-database operation: Transport never deploys to or otherwise touches site cluster nodes — newly imported instances land as NotDeployed, and the operator redeploys via the existing pipeline.
Location
- New project:
src/ZB.MOM.WW.ScadaBridge.Transport/ - New tests:
tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/,tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ - Central UI pages:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportExport.razor,TransportImport.razor - EF migration in
src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/(addsBundleImportIdcolumn toAuditLogEntries) - This design doc:
docs/requirements/Component-Transport.md
Responsibilities
- Define and own the
.scadabundlefile format (ZIP container,manifest.json,content.json/content.enc). - Resolve artifact dependencies at export time: base templates, shared scripts, external systems, template folders, notification lists, SMTP configs, SMS configs, API methods.
- Inbound API keys are deliberately NOT transportable. They live in each environment's own secret store (per-env pepper, secret shown once) and cannot be exported — see the comment on
ExportSelection. Only API methods travel. After importing into a destination environment, an operator must re-create the keys there and re-grant their method scopes via the admin UI or CLI. - Move site-scoped configuration (T18):
Sitedefinitions, site-scopedDataConnections (protocol connections — distinct from External-SystemDatabaseConnections), andInstances along with theirInstanceAttributeOverride/InstanceAlarmOverride/InstanceNativeAlarmSourceOverride/InstanceConnectionBindingchildren andAreamembership (carried by name). - Reconcile cross-environment site identifiers and connection names through the name-mapping subsystem (
BundleNameMap): auto-match by identifier/name, operator override via the import-wizard Map step or CLI flags, and per-conflict create-or-bind resolution (see "Name Mapping"). - Compute a per-line (Myers) diff for code fields on Modified artifacts (T20) via the pure
LineDiffer, embedding a size-capped structured line diff in eachArtifactDiff. Two independent caps guard the diff: a 400-line output cap on the emitted hunk list, and — as of arch-review 05 (Task 22) — aMaxInputLinesinput cap (4000 combined lines) that short-circuits the O((N+M)²) Myers trace to a summary-only result (Truncated = true, add/remove totals only) so a bloated or crafted bundle cannot OOM the active central node from the import preview. - Serialize and deserialize all transportable entity types to/from bundle DTOs, carving secret fields into an isolated
SecretsBlock. A template travels with all of its child collections —TemplateAttribute,TemplateAlarm,TemplateScript,TemplateComposition, andTemplateNativeAlarmSource(includingIsInheritedplaceholder rows, which the collision detector depends on) — each field-faithful on Add and reconciled add/update/delete on Overwrite. Field completeness is verified by a reflection round-trip guard over every writable property (Add + Overwrite), and includes the fields earlier revisions dropped: the derived-lock flagLockedInDerivedon attributes/alarms/scripts, script cadence/timeout (MinTimeBetweenRuns/ExecutionTimeoutSeconds), and — for instances — the realAreaName(the exporter previously emittednull, half-shipping the Area-by-name feature). Carrying the native alarm sources is what keeps an importedInstanceNativeAlarmSourceOverridefrom dangling against a source the target template would otherwise lack. - Encrypt content with AES-256-GCM + PBKDF2-SHA256 (600 000 iterations) when a passphrase is supplied; leave content plaintext with a UI warning and an
UnencryptedBundleExportaudit event when none is given. - Validate
manifest.jsonon upload: format version gating, SHA-256 content hash verification. - Manage in-memory
BundleSessionobjects: 30-minute TTL, 3-strike passphrase lockout per session. - Compute a per-artifact diff between bundle contents and the target environment, classifying each artifact as Identical, Modified, New, a Blocker (import-stopping), or a Warning (advisory, non-blocking).
- Apply user-supplied conflict resolutions (Add, Overwrite, Skip, Rename) in a single EF transaction, running multi-pass semantic validation before committing: a script trust gate (
ScriptTrustValidator.FindViolationsover every non-Skip executable C# surface a bundle carries — template / shared / ApiMethod script bodies, template script and alarm Expression-trigger bodies, and instance alarm-override trigger expressions (InstanceAlarmOverrideDto.TriggerConfigurationOverride); bundle import is the fifth script-trust call site, so forbidden-API scripts are rejected at import review, not deferred to runtime — all these surfaces hard-block uniformly, since the trust gate's semantic verdict is authoritative with no false-positive channel, and the advisory-vs-hard severity split is a property of the name-resolution heuristic only), then a minimal name-resolution scan over the merged target (ApiMethod findings fail fast on unresolved SharedScript / ExternalSystem identifiers; template-script findings are advisory warnings), then the fullSemanticValidatorfromZB.MOM.WW.ScadaBridge.TemplateEngineover each imported template's per-templateFlattenedConfiguration. - Emit
BundleExported,BundleImported,BundleImportFailed,UnencryptedBundleExport,BundleImportUnlockFailed,BundleImportAlarmScriptUnresolved,BundleImportCompositionUnresolved, andBundleImportBaseTemplateUnresolvedaudit events viaIAuditService. - Thread a
BundleImportIdcorrelation GUID through every per-entityAuditLogEntrywritten duringApplyAsyncvia a scopedIAuditCorrelationContext. - Enforce
RequireDesignon export andRequireAdminon import both at the Razor page layer and inside the service entrypoints (defense in depth).
Bundle Format
File Layout
.scadabundle is a renamed .zip:
bundle.scadabundle
├── manifest.json # required, NOT encrypted
├── content.json # plaintext artifact data (when no passphrase)
└── content.enc # encrypted artifact data (when passphrase set)
Exactly one of content.json or content.enc is present. Script bodies (template
scripts, shared scripts, ApiMethod scripts) travel inline inside the content
blob, not as separate scripts/ files — a well-formed bundle has exactly two zip
entries (manifest.json + one content blob). A separate scripts/ directory is
not implemented: TransportOptions.MaxBundleEntryCount (default 4) is a
zip-bomb guard, and the exporter never emits per-script files.
manifest.json (plaintext)
{
"bundleFormatVersion": 1,
"schemaVersion": "1.1",
"createdAtUtc": "2026-05-24T12:34:56Z",
"sourceEnvironment": "dev-cluster-a",
"exportedBy": "alice@corp.example",
"scadaBridgeVersion": "1.4.2",
"contentHash": "sha256:...",
"encryption": {
"algorithm": "AES-256-GCM",
"kdf": "PBKDF2-SHA256",
"iterations": 600000,
"saltB64": "...",
"ivB64": "..."
},
"summary": {
"templates": 12, "templateFolders": 3, "sharedScripts": 4,
"externalSystems": 2, "dbConnections": 1,
"notificationLists": 1, "smtpConfigs": 0, "smsConfigs": 0, "apiMethods": 5,
"sites": 2, "dataConnections": 3, "instances": 8
},
"contents": [
{ "type": "Template", "name": "Pump", "version": 5,
"dependsOn": ["SharedScript:PumpUtils"] },
{ "type": "Template", "name": "Pump.WaterPump", "version": 3,
"dependsOn": ["Template:Pump", "ExternalSystem:HistorianAPI"] },
{ "type": "Site", "name": "site-a", "version": 1, "dependsOn": [] },
{ "type": "DataConnection", "name": "site-a/PlantOpcUa", "version": 1,
"dependsOn": ["Site:site-a"] },
{ "type": "Instance", "name": "WaterPump-01", "version": 1,
"dependsOn": ["Template:Pump.WaterPump", "Site:site-a"] }
]
}
The manifest is plaintext so the import wizard can preview bundle contents and source provenance before the user supplies a passphrase. (Implementation note: BundleImporter.LoadAsync always parses the manifest and reads the content blob to verify the SHA-256 hash on every call, regardless of whether a passphrase is available — the "manifest peek" is conceptual rather than a cheap O(manifest) operation. For an encrypted bundle without a passphrase the call surfaces the encrypted-bundle prompt via the validated envelope, so the UI gets the manifest + provenance for free, but the cost is O(bundle-size) per LoadAsync. A future ReadManifestAsync(Stream) that skips the content read is a deferred optimisation.)
content.json / content.enc
- One top-level array per entity type, POCO shapes serialized via
System.Text.Json. As of schema 1.1 (T18) this includessites,dataConnections, andinstancesarrays (SiteDto,DataConnectionDto,InstanceDto+ its override-child and connection-binding DTOs) alongside the existing central-config arrays. - Secret fields (API key hashes, SMTP password, external system credentials, DB connection passwords) live in a nested
secretsblock on each affected entity. Per the D3 "carry full config (encrypted secrets)" decision, aDataConnection'sPrimaryConfiguration/BackupConfiguration(endpoint + credentials) also ride inside this encryptedSecretsBlock; they appear in diffs as presence-only and are never echoed in plaintext. ASite'sNodeA/NodeBandGrpcNodeA/GrpcNodeBaddresses travel as ordinary (non-secret)SiteDtofields. - The whole
contentblob is AES-256-GCM encrypted with a key derived via PBKDF2-SHA256 (600 000 iterations) from the user's passphrase, with per-bundle random salt and per-encryption random IV. - Unencrypted bundles are allowed but the UI warns and audit-tags them
UnencryptedBundleExport.
Forward Compatibility
- Unknown top-level entity types in
contents[]surface in the import preview as "skipped — unsupported in this version" rather than failing the whole import. bundleFormatVersionnewer than what the importer supports produces a hard refusal at upload with a clear upgrade message.schemaVersionminor increments are additive-only. T18 bumped it1.0 → 1.1(adding thesites/dataConnections/instancesarrays).bundleFormatVersionstays1;ManifestValidatorgates solely onbundleFormatVersion, so a pre-T18 importer still accepts a 1.1 bundle and simply lists the new site/instance/connection entries as "skipped — unsupported in this version".
Architecture
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
subgraph T["ZB.MOM.WW.ScadaBridge.Transport"]
EXPORTER["IBundleExporter<br/>ExportAsync(ExportSelection, Passphrase?, ct) → Stream"]
IMPORTER["IBundleImporter<br/>LoadAsync(stream, Passphrase?, ct, ClientIp?) → BundleSession<br/>PreviewAsync(sessionId, ct) → ImportPreview<br/>ApplyAsync(sessionId, resolutions, ct) → ImportResult<br/>Abandon(sessionId)"]
RESOLVER["DependencyResolver"]
SERIALIZER["BundleSerializer<br/>(manifest + content JSON; ZIP packer)"]
ENCRYPTOR["BundleSecretEncryptor<br/>(AES-256-GCM + PBKDF2)"]
SESSIONSTORE["BundleSessionStore<br/>(in-memory, TTL'd)"]
MANIFESTVALIDATOR["ManifestValidator<br/>(schema/version gating, hash check)"]
end
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class EXPORTER,IMPORTER proc
class RESOLVER,SERIALIZER start
class ENCRYPTOR alt
class SESSIONSTORE warn
class MANIFESTVALIDATOR dec
class T muted
The component is central-hosted. It is registered in ZB.MOM.WW.ScadaBridge.Host for central roles only, never for site roles — even when a bundle carries site-scoped config (T18), Transport only writes the central configuration database, never the site clusters. All persistence flows through existing audited repository interfaces in ZB.MOM.WW.ScadaBridge.ConfigurationDatabase — the component does not call DbContext.SaveChangesAsync directly. BundleSessionStore is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session, and a MaxConcurrentImportSessions cap (default 8; arch-review 05 Task 24 — startup-validated as > 0 by TransportOptionsValidator, since a zero/negative cap would reject every import session forever) bounding the decrypted-content footprint since each open session pins a fully-decrypted bundle in memory. The cap is observable (register row 53): open (unexpired) sessions are published as the scadabridge.central.transport.import_sessions.open gauge on the ZB.MOM.WW.ScadaBridge meter — already in the ObservedMeters allowlist, so no telemetry config change is needed — and a rejection at the cap logs at Warning with the current count, the cap and the TTL. Reclaiming a store full of expired sessions is not a cap hit and logs nothing. A log line is not alertable, so register row 66 adds the counter scadabridge.central.transport.import_sessions.rejected on the same meter, incremented at the same single genuine-rejection point (the post-eviction recheck — never on the reclaim-then-succeed path, and never when re-opening an already-open session id). Alert on the counter; interpret it against the gauge — rejections rising while the gauge stays below the cap means genuine concurrency (raise the cap), whereas rejections rising while the gauge is pinned at the cap means a leaked session (raising the cap only defers it).
Session release is explicit, not TTL-only (register row 43). ApplyAsync releases the session it applied (zero-then-remove, T-007), and IBundleImporter.Abandon(sessionId) does the same for a session that will never be applied. Every abandonment path calls it: the Central UI wizard on new upload / Back-to-Upload / ResetSessionState / lockout reset / Dispose (navigation away or circuit teardown), the CLI transport preview handler (a preview never applies), and the CLI transport import handler for the aborts that precede apply (blocker rows, unresolved mappings). Abandon is idempotent — double release, an unknown id, and an already-expired id are all silent no-ops — and since session ids are random GUIDs handed only to their opener, abandoning the id you hold can never evict another operator's in-progress import. Relying on the TTL alone was an availability and secret-lifetime defect: eight abandoned wizards exhausted MaxConcurrentImportSessions and locked out every import on the active node for 30 minutes, each still holding its decrypted bundle content.
Preview→apply window (recorded decision). The diff/preview an operator reviews is a point-in-time snapshot; there is no optimistic concurrency between preview and apply. If the target config changes in the window between PreviewAsync and ApplyAsync (another admin edits a template, say), the apply proceeds against the current state — the resolutions are keyed by (EntityType, Name), so they still target the right rows, but the operator may not have seen the latest state in the diff. This is accepted for v1 (single-operator import is the expected workflow, and the terminal Confirm step already gates a typo-resistant environment-name entry). The bundle-format version fields are reserved for a future optimistic-concurrency check but are not yet enforced (Underdeveloped 5).
Export Flow
UI — 4-Step Wizard (Design nav group)
Step 1 — Select artifacts. Templates are rendered as a tree matching the existing Templates page (the TemplateFolderTree.razor shared component, used in its new checkbox-selection mode). Tri-state checkboxes on folders (☑ all, ☐ none, ▣ partial). Search filters the tree in place. Other artifact groups (shared scripts, external systems, notification lists, SMTP configs, SMS configs, API methods) are flat checkbox lists. API keys are not offered — they are not transportable (see Scope).
A Sites & Instances section (T18) adds a flat list of sites, each row expandable to its instances; selecting a site or individual instances pulls them (and their site-scoped DataConnections) into the bundle. The wizard distinguishes operator-seeded selections from artifacts auto-included by dependency resolution (e.g., an instance's site and bound connections).
Step 2 — Review dependencies. The resolver expands the user's selection along these edges:
Template AcomposesTemplate B→ includeB.TemplatereferencesSharedScript(by name) → include the script.TemplatereferencesExternalSystem→ include the definition and its methods.ApiMethodreferencesSharedScript→ include the script.NotificationListdoes not pull in anSmtpConfiguration. SMTP (and SMS) configurations are environment-specific and are only ever included when the operator selects them explicitly — the resolver walksExportSelection.SmtpConfigurationIdsalone.- Any folder containing a selected template is included so the structure is reproducible on import.
The user can toggle "include all dependencies" off (with a warning that the bundle may produce an invalid import).
Step 3 — Encryption. Passphrase and confirm. Strength meter. Explicit warning lists every secret field being encrypted. An "Export without encryption" path is logged with the UnencryptedBundleExport audit flag.
Step 4 — Download. Generated filename pattern: scadabundle-{sourceEnv}-{yyyy-MM-dd-HHmmss}.scadabundle. SHA-256 displayed for out-of-band verification.
Backend
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
USER(["User (Design role)"])
WIZARD["Central UI Export wizard"]
EXPORTER["IBundleExporter"]
RESOLVER["DependencyResolver"]
REPOS[("repositories (read)")]
SERIALIZER["EntitySerializer"]
CONTENTJSON["content.json"]
ENCRYPTOR["BundleSecretEncryptor"]
CONTENTENC["content.enc<br/>(if passphrase)"]
MANIFESTBUILDER["ManifestBuilder"]
MANIFESTJSON["manifest.json"]
ZIP["ZIP packer → temp file → browser download"]
AUDIT["IAuditService.LogAsync(BundleExported …)"]
USER --> WIZARD
WIZARD --> EXPORTER
EXPORTER --> RESOLVER
RESOLVER --> SERIALIZER
SERIALIZER --> ENCRYPTOR
ENCRYPTOR --> MANIFESTBUILDER
MANIFESTBUILDER --> ZIP
ZIP --> AUDIT
RESOLVER --> REPOS
SERIALIZER --> CONTENTJSON
ENCRYPTOR --> CONTENTENC
MANIFESTBUILDER --> MANIFESTJSON
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class USER,AUDIT start
class WIZARD,EXPORTER,ZIP proc
class RESOLVER,SERIALIZER,MANIFESTBUILDER dec
class ENCRYPTOR alt
class CONTENTJSON,CONTENTENC,MANIFESTJSON warn
class REPOS muted
Audit event: BundleExported — caller, artifact count, content hash, encrypted yes/no, bundle filename.
Authorization: RequireDesign on both the Razor page and IBundleExporter.ExportAsync.
Import Flow
UI — 5-Step Wizard (Admin nav group)
Upload → Passphrase → Diff → Confirm → Result. The Map step is not a sixth pill: name mapping is a section rendered inside the Diff step.
Step 1 — Upload. Drag-and-drop or browse. On selection, the manifest is parsed and displayed (source env, exporter, timestamp, content count, SHA-256, encrypted yes/no). The manifest hash is validated against the content blob.
Step 2 — Passphrase (skipped if the bundle is unencrypted). 3-wrong-attempt lockout invalidates the session.
Step 3 — Map (T18; shown only when the bundle carries sites and/or site-scoped connections). For every source Site and every source (site, connection) the importer first auto-matches against the target environment (sites by SiteIdentifier, connections by the site-qualified {SiteIdentifier}/{Name} key). The operator confirms or overrides each row: Map to existing (bind to a target site/connection) or Create new (materialize it from the bundle payload). Any source reference that neither auto-matches nor is set to Create-new becomes a blocker row and Apply stays disabled until resolved. The Map step renders only the rows that still require a decision; fully auto-matched references pass through silently.
Step 4 — Diff & resolve conflicts. For each artifact in the bundle, compare to existing by name:
- Identical (field-by-field) → marked, auto-skipped, cannot be selected.
- Modified → shows a real per-line
+/-diff for code fields (TemplateScript.Code,SharedScript.Code,ApiMethod.Script), rendered by the sharedLineDiffViewcomponent from the structuredlineDiffpayload (T20). User picks Skip / Overwrite / Rename. - New → marked
+ Add. User can opt to skip individually.
Bulk "Apply to all" at the top (Overwrite / Skip / Rename), overridable per row. Summary line at the bottom: "5 add · 2 overwrite · 1 skip · 0 rename".
Bundle references that cannot be satisfied in either the bundle or the target DB (e.g., a template references a shared script that is neither in the bundle nor pre-existing) appear as blocker rows — Apply is disabled until they are resolved (typically by skipping the dependent artifact or re-exporting with dependencies). Unmapped/unmatched sites and connections that the operator did not set to Create-new also surface as blocker rows here, mirroring the Step-3 Map decisions.
Blocker-scan heuristic boundaries. The scanner walks TemplateScript.Code, TemplateAttribute.Value, and ApiMethod.Script looking for top-level Identifier( or Identifier. tokens. To keep the heuristic usable on real script bodies it (a) skips identifiers preceded by . (member access — obj.Method() does not flag Method); (b) does NOT scan TemplateAttribute.DataSourceReference (an OPC UA address path, never script source); (c) excludes names declared locally in the body — every LocalFunctionStatement / MethodDeclaration identifier is collected via a Roslyn script-kind parse, so a script's own helper (decimal ComputeRate(...) => …; return ComputeRate(x);) is not mistaken for a missing SharedScript; and (d) filters out a KnownNonReferenceNames denylist of .NET stdlib types (Convert, DateTimeOffset, Regex, StringBuilder, Parse, LINQ operators, …), ScadaBridge runtime API roots (Notify, Database, ExternalSystem, Scripts, Instance, Parameters, Attributes, Route, …), and common SQL keywords that appear inside string literals (COUNT, SELECT, FROM, HAVING, VALUES, …). Both the diff-step DetectBlockersAsync and the Apply-time RunSemanticValidationAsync Pass 1 share this filter, so the diff preview and the Apply gate agree.
Severity split — template-script findings are advisory, ApiMethod findings block. A residual unresolved identifier (one the heuristic could not filter) is classified by its origin. Findings from template scripts and attribute default expressions are non-blocking warnings: they surface as ConflictKind.Warning rows in the preview and ride on ImportResult.Warnings after a successful apply — the import proceeds because the authoritative design-time deploy gate re-validates the script with a real compile (ScriptCompiler.TryCompile), so a heuristic false positive here must never stop a valid import. Findings from ApiMethod scripts remain hard errors (ConflictKind.Blocker in preview; SemanticValidationException at apply) because inbound API methods have no downstream design-time gate — the import review is their only pre-runtime check. The split is applied identically in DetectBlockersAsync and RunSemanticValidationAsync Pass 1 so preview and apply agree. (This advisory-vs-hard split is a property of the name-resolution heuristic only — it is false-positive-prone; the script trust gate above has no false-positive channel, so its verdict is a hard error for every gated surface, template trigger expressions and instance alarm-override trigger expressions included.)
Locked-member override warnings (advisory). An instance override — attribute (FlatteningService drop), alarm, or native-alarm-source — that targets a locked template member is silently inert: the flattener ignores it. To stop that being invisible, a best-effort scan (CollectLockedOverrideWarnings) surfaces each such override as a ConflictKind.Warning preview row and on ImportResult.Warnings after apply, matching locked members by direct name (own + inherited placeholder rows both carry the IsLocked flag). It never blocks the import and never changes what is written — the override row is still persisted verbatim (bundle fidelity); the flattener remains the behavioural authority, and composed path-qualified members or derived-shadow (LockedInDerived) locks a placeholder row does not reflect emit no warning (never a false positive — the ManagementActor and flattener stay the enforcement points).
Script-artifact change bus (post-commit). After a successful apply the importer publishes one ScriptArtifactsChanged per script-bearing kind (ApiMethod / SharedScript / Template) for every non-Skip resolution — Overwrite, Rename, AND Add — because an artifact deleted on the target and re-imported as Add under the same name can still leave a stale compiled-handler/_knownBadMethods entry on a node; over-approximation is safe (the bus is advisory, at-least-once, and consumers self-heal). The subscriber side (Inbound API cache invalidation wiring) lives with the Inbound API component — see PLAN-R2-06.
Step 5 — Confirm. Final summary plus a "N instances will become stale" warning enumerating affected instances (a real count as of M8 — see "Stale-Instance Signaling"). User types the source environment name to confirm (typo-resistant gate at the prod boundary).
Step 6 — Result. Counts, link to the BundleImported audit row, link to the Deployments page filtered to the newly stale instances.
Backend
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
USER(["User (Admin role) → uploads bundle"])
LOAD["IBundleImporter.LoadAsync<br/>· verify SHA-256 (manifest vs content)<br/>· check bundleFormatVersion supported<br/>· decrypt content.enc with passphrase (if encrypted)<br/>· deserialize entities<br/>· open BundleSession (30-min TTL)"]
PREVIEW["PreviewAsync → diff vs target DB → ImportPreview"]
REVIEW["(user reviews + resolves conflicts)"]
APPLY["ApplyAsync(resolutions, BundleNameMap?) — single EF transaction<br/>· run two-tier semantic validation<br/> (minimal name scan + full SemanticValidator)<br/>· resolve/create target sites + connections (name map)<br/>· apply resolutions (add / overwrite / skip / rename)<br/>· upsert TemplateFolder hierarchy<br/>· upsert instances (forced NotDeployed); rewire FKs<br/>· IAuditService.LogAsync(BundleImported …)<br/>· commit"]
RESULT["ImportResult → UI step 6"]
DEPLOYMENTS["'View on Deployments →' (existing page)"]
USER --> LOAD
LOAD --> PREVIEW
PREVIEW --> APPLY
PREVIEW -.- REVIEW
APPLY --> RESULT
RESULT --> DEPLOYMENTS
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class USER start
class LOAD,RESULT proc
class PREVIEW dec
class APPLY alt
class DEPLOYMENTS warn
class REVIEW muted
Authorization: RequireAdmin on both the Razor page and IBundleImporter.* entrypoints.
Name Mapping
Site identifiers and connection names are environment-specific, so a bundle exported from dev-cluster-a cannot assume those identifiers exist (or mean the same thing) on the target. The name-mapping subsystem reconciles source → target references at import time.
Model (ZB.MOM.WW.ScadaBridge.Commons). BundleNameMap carries two collections:
SiteMapping(SourceSiteIdentifier, Action, TargetSiteIdentifier?)— per source site.ConnectionMapping(SourceSiteIdentifier, SourceConnectionName, Action, TargetConnectionName?)— per source(site, connection).
Action is the MappingAction enum: MapToExisting (bind the source reference to a named target site/connection, honouring the chosen conflict resolution) or CreateNew (materialize the site/connection from the bundle payload — full config, including the encrypted DataConnection secrets per D3). BundleNameMap.Empty is the no-mapping default for central-only bundles.
Identity keys. Sites are matched by SiteIdentifier; connections by the site-qualified key {SiteIdentifier}/{Name}; instances by UniqueName.
Auto-match → operator override → apply. The importer first auto-matches every source site and connection against the target by these identity keys. The operator confirms/overrides via the Central UI Map step (Step 3) or the CLI --map-site / --map-connection / --create-missing-* flags. References that neither auto-match nor are set to Create-new become blocker rows (UI) — or, on the CLI without --create-missing-sites / --create-missing-connections, abort before apply.
Template second-pass rewires. Bundles carry template-to-template references by name, not id (ids are environment-specific). After the templates are flushed, the importer runs three name-resolution passes over the imported templates so every cross-template FK points at the freshly-persisted target row: the alarm on-trigger-script links (TemplateAlarm.OnTriggerScriptId), the composition edges (TemplateComposition), and the inheritance edges (Template.ParentTemplateId, resolved from BaseTemplateName through the same rename/skip resolution map — a null base clears a stale edge on Overwrite). Each pass is best-effort: an unresolvable reference leaves the FK null and emits a warning audit row (BundleImportAlarmScriptUnresolved / BundleImportCompositionUnresolved / BundleImportBaseTemplateUnresolved) rather than aborting the import.
Rename call-site limitation (load-bearing). A Rename resolution renames the entity being imported (e.g. a SharedScript Foo → Bar), but it does not rewrite call sites inside importing scripts — a script body that calls CallShared("Foo") still says Foo after import. So the target DB can end up with scripts referencing the old name. This is deliberate: Pass 1 name resolution registers both the original and renamed names as resolvable (so the import isn't blocked), and the authoritative check is the target's own deploy-time validation (ScriptCompiler.TryCompile in the Template Engine deploy gate), which recompiles against the actual persisted catalog. Operators renaming a shared dependency must fix the calling scripts on the target (or re-export with the dependency included under its original name).
FK rewiring at apply. Inside the single EF transaction the importer resolves or creates the target sites and connections first, then upserts instances (forced to NotDeployed) and rewires their foreign keys against the resolved targets:
InstanceConnectionBinding.DataConnectionIdis resolved via the connection map, with a Pass-2 that also binds connections which already exist in the target but were not carried in the bundle.InstanceNativeAlarmSourceOverride.ConnectionNameOverrideis rewritten to the mapped target connection name.
Stale-Instance Signaling
There is no explicit stale-mark write. Overwriting a template during import changes its content, which naturally changes the flattened-config hash that DeploymentService.CompareAsync computes at query time. The Deployments page already surfaces any instance whose computed hash differs from the deployed snapshot — no new mechanism is required.
As of M8 the import result enumerates affected instances for real (closing the prior StaleInstanceIds stub). Before commit, for each Overwritten template the importer walks the existing deployed target instances of that template and, via the IStaleInstanceProbe seam in Commons (implemented in the Deployment Manager over the flattening pipeline), compares each instance's freshly-flattened revision hash against its DeployedConfigSnapshot.RevisionHash; instances whose hash has drifted are returned in ImportResult.StaleInstanceIds. The probe is optional — when it is not registered the result is an empty set (informational only, never blocking). Freshly-imported NotDeployed instances are surfaced as new, not stale. Step 5 (Confirm) shows the real count and Step 6 (Result) deep-links the Deployments page filtered to those instances.
Error Handling
| Where | Failure | Surfaced as |
|---|---|---|
| Upload | Not a zip / missing manifest.json |
Step 1 error: "Not a valid ScadaBridge bundle" |
| Upload | bundleFormatVersion newer than supported |
Step 1 error: "Bundle was created by ScadaBridge v{x}; upgrade this cluster" |
| Upload | Content hash mismatch | Step 1 error: "Bundle integrity check failed — file may be corrupt" |
| Unlock | Wrong passphrase | Step 2 error; 3rd wrong attempt invalidates session, audit BundleImportUnlockFailed |
| Map | Source site/connection neither auto-matched nor mapped/created | Listed as a blocker row in the Map step (UI) / Diff step; cannot Apply until mapped or set to Create-new. CLI aborts before apply unless --create-missing-sites / --create-missing-connections is given |
| Map | CreateNew selected but the bundle does not carry that site/connection's config |
Blocker — Create-new requires the full config to be present in the bundle payload |
| Preview | ApiMethod script references shared script / external system not in bundle and not in target DB | Listed as a blocker row in the Diff step; cannot Apply until resolved (no downstream deploy gate for ApiMethods) |
| Preview | Template script references shared script / external system not in bundle and not in target DB | Listed as an advisory warning row; import proceeds — the design-time deploy gate re-validates the script authoritatively |
| Apply | Semantic validation fails (call target type mismatch, etc.) | Modal: "Validation failed — N errors", per-error list, no DB writes |
| Apply | DB transaction fails | Rollback; full transactional guarantee — nothing partial lands; BundleImportFailed audit row written outside the rolled-back transaction |
| Session | TTL expired | Diff step onward, refresh prompts re-upload |
Imports are all-or-nothing per bundle. A bundle either applies fully or not at all.
Security
- AES-256-GCM for content encryption with PBKDF2-SHA256 / 600 000 iterations (OWASP 2023+ guidance), per-bundle random salt, random IV per encryption. The GCM auth tag is verified before decryption — a wrong passphrase fails cleanly.
- Passphrase never persisted. It lives only inside the export/import service call path and is discarded after use. No environment variable, no log line.
- Failed-unlock rate limit: per-bundle 3-strike lockout (keyed by
ContentHash, server-side); per-IP-per-hour cap (MaxUnlockAttemptsPerIpPerHour, default 10, configurable) to deter brute force against a stolen bundle. Each failed attempt produces aBundleImportUnlockFailedaudit row. The two are evaluated in order — bundle lockout, then per-IP throttle, then decrypt — so hammering an already-locked bundle spends no hourly budget, and a throttle denial (BundleUnlockThrottledException, fail-closed with a curated message on both the UI and CLI surfaces) burns no per-bundle strike, since it never reaches the decryptor. Both typed exceptions live inCommons/Types/Transport/—ManagementServicereferences Commons but not Transport, so a type declared beside the importer cannot be curated byMapFaultand reaches the CLI as a generic internal-error message (register rows 44 and 51). Successful unlocks count against the hourly budget too: it caps attempts, not failures. The client key is the caller's remote IP — fromCircuitClientAddressProvider(snapshotted at Blazor circuit establishment, sinceHttpContextis unavailable mid-circuit) for the Central UI, and fromManagementEnvelope.ClientIpfor the CLI; a caller with no attributable address shares a single stricter bucket rather than bypassing the throttle. Per-client isolation is only real behind a ForwardedHeaders-trusted proxy, the same caveat the login throttle carries inComponent-Security.md. (Register row 44: the limiter existed, was configured and unit-tested, but was never invoked until this was wired.)
The per-IP window is fleet-wide and restart-durable (register row 52). It is persisted in the central MS SQL BundleUnlockAttempt table — the HA store both central nodes already share — rather than in each node's process memory, so the documented cap is a fleet cap and not N × the cap, and a node restart no longer clears it. BundleUnlockRateLimiter still owns the policy; SqlBundleUnlockAttemptWindow is the store behind the IBundleUnlockAttemptWindow seam, and IBundleUnlockAttemptRepository (Commons interface, ConfigurationDatabase implementation) is the persistence. Three deliberate trades: the throttle fails OPEN — a store error logs a Warning and permits the attempt, because this is a best-effort rate cap over an already-authenticated Administrator-gated surface and the availability of legitimate imports outranks the cap; accuracy is approximate — count-then-insert is not atomic across nodes, so simultaneous attempts can overshoot by the number in flight, which is bounded and self-corrects within the window; and rows are pruned best-effort (CAS-claimed at most every 15 minutes per process, bounded to 1000 rows, retaining twice the window). That prune originally rode the unlock path alone, since Transport owned no central scheduler — which meant a cluster that stopped importing entirely left its last rows in place indefinitely. Register row 65 closed that: Transport's central-only BundleSessionEvictionService sweep now also ticks IBundleUnlockAttemptWindow.PruneAsync, sharing the one 15-minute cadence claim with the unlock path, so the table converges with no import traffic at all. PruneAsync never throws (the fail-open policy stays with the limiter on the attempt path).
- Bundle size cap on upload (default 100 MB, configurable) to bound memory.
- In-transit: existing HTTPS to the Central UI; no new channel.
- Audit trail is the chain of custody. Every export, every import (including aborted ones at validation), and every unlock failure is audit-logged with source env, content hash, encrypted yes/no, and artifact summary.
- Defense in depth on authorization:
RequireDesign(export) andRequireAdmin(import) are enforced both on the Razor page and insideZB.MOM.WW.ScadaBridge.Transportservice entrypoints. The UI is not the only gate. - Bundles are not retained server-side after download (export) or after
ApplyAsynccommits (import).
Configuration Audit Trail
Import flows through the same audited repository methods the UI and CLI use, so every artifact mutated by ApplyAsync emits the existing per-entity AuditLogEntry row:
| Action during import | AuditLogEntries rows emitted |
|---|---|
| Template added | TemplateCreated + TemplateAttributeCreated (×N) + TemplateScriptCreated (×N) + … |
| Template overwritten | TemplateUpdated + per-field rows (TemplateAttributeAdded, TemplateScriptUpdated, …) |
| Template skipped | (no rows) |
| Template renamed-on-import | TemplateCreated with the new name (existing row untouched) |
| External system overwritten | ExternalSystemDefinitionUpdated + per-method rows |
| Notification list added | NotificationListCreated + per-recipient rows |
| API key added | ApiKeyCreated |
| Imported alarm references missing on-trigger script | BundleImportAlarmScriptUnresolved (warning; alarm FK left null) |
| Imported template's composition references missing target template | BundleImportCompositionUnresolved (warning; composition row not written) |
| Imported derived template's base template missing (unresolved / skipped) | BundleImportBaseTemplateUnresolved (warning; ParentTemplateId left null) |
Correlation: every per-entity row written during an import carries a new optional BundleImportId column (the GUID of the parent BundleImported summary row). The repository layer (CentralUiRepository.QueryConfigurationAuditAsync) already accepts a BundleImportId filter parameter. The Configuration Audit Log Viewer UI surface — a "Bundle Import" filter dropdown and a hyperlink on the BundleImported summary row that pre-populates that filter — is a deferred UI follow-up (tracked under Transport-012 in the code review backlog). Until then, an operator can drive the same filter via the CLI audit query --bundle-import-id <guid>.
Schema change: one EF migration adds:
BundleImportId uniqueidentifier NULLonAuditLogEntries.- Non-clustered index
IX_AuditLogEntries_BundleImportId.
Transactional guarantee: the EF transaction wraps both the entity writes and the audit log writes (existing pattern). A rollback removes both. The BundleImported summary row is the final write inside the transaction, so partial audit trails are impossible. A failed import emits a single BundleImportFailed row outside the rolled-back transaction.
CLI
Three commands surface the same Transport operations as the Central UI wizards, designed for test automation. The bundle bytes travel as base64 inside the existing /management JSON envelope — no new HTTP endpoints — and the per-request body cap is raised to 200 MB to cover the 100 MB raw-bundle ceiling once base64-inflated.
scadabridge bundle export --output FILE --passphrase X [--all | --templates A,B ...] \
[--shared-scripts ...] [--external-systems ...] [--db-connections ...] \
[--notification-lists ...] [--smtp-configs ...] [--sms-configs ...] \
[--api-methods ...] [--sites A,B ...] [--instances X,Y ...] \
[--include-dependencies] [--source-environment NAME]
scadabridge bundle preview --input FILE --passphrase X
# prints PreviewBundleResult JSON: per-row items + add/modified/identical/blocker counts;
# also prints a required-mapping summary — for each source site/connection it states the
# auto-match ("auto-matches 'X'") or the directive ("use --map-site / --create-missing-sites")
scadabridge bundle import --input FILE --passphrase X [--on-conflict skip|overwrite|rename] \
[--map-site src=dst ...] [--map-connection srcSite/srcName=dstName ...] \
[--create-missing-sites] [--create-missing-connections]
# one-shot load + preview + apply with a single global policy for Modified rows
# Identical → Skip, New → Add, Blocker → abort
Selection uses entity names rather than IDs so scripts are portable across environments — --sites accepts a SiteIdentifier (preferred) or friendly name per token, --instances accepts a UniqueName. The mapping flags are repeatable; a --map-site/--map-connection token with no =dst part (or = with an empty right-hand side) means Create-new, otherwise it binds to the named existing target. --create-missing-sites / --create-missing-connections create any still-unmapped source site/connection from the bundle payload instead of aborting. The CLI per-command timeout is 5 minutes (vs the default 30 s for other commands) to comfortably cover large bundles. CLI commands route through ManagementActor's ExportBundleCommand / PreviewBundleCommand / ImportBundleCommand handlers, which delegate to the same IBundleExporter / IBundleImporter services as the UI.
Exit codes follow the project convention: 0 = success, 1 = command failure (validation, blockers, wrong passphrase), 2 = authorization failure.
Authorization
| Operation | Required role | Enforced at |
|---|---|---|
Open Export page / bundle export CLI |
RequireDesign |
Razor page authorize attribute + ManagementActor.GetRequiredRole |
IBundleExporter.ExportAsync |
RequireDesign |
Service entrypoint |
Open Import page / bundle preview + bundle import CLI |
RequireAdmin |
Razor page authorize attribute + ManagementActor.GetRequiredRole |
IBundleImporter.LoadAsync / PreviewAsync / ApplyAsync |
RequireAdmin |
Service entrypoint |
| Configuration Audit Log "Bundle Import" filter | RequireAdmin or Audit |
Existing audit page logic |
Dependencies
ZB.MOM.WW.ScadaBridge.Commons— Bundle manifest and content DTOs (BundleManifest,ExportSelection,ImportPreview,ImportResolution,ImportResult,BundleSession); the name-mapping model (BundleNameMap,SiteMapping,ConnectionMapping,MappingAction); transport interface definitions (IBundleExporter,IBundleImporter,IBundleSessionStore,IAuditCorrelationContext,IStaleInstanceProbe).ZB.MOM.WW.ScadaBridge.ConfigurationDatabase— All repository implementations andIAuditServicefor persistence and per-entity audit emission;IAuditCorrelationContextimplementation (AuditCorrelationContext) registered as a scoped service; EF migration forBundleImportId. Site/DataConnection/Instancetransport (T18) reuses the existing entities — no new tables or columns.ZB.MOM.WW.ScadaBridge.DeploymentManager—IStaleInstanceProbeimplementation (StaleInstanceProbe) over the flattening pipeline, supplying the realImportResult.StaleInstanceIdsenumeration (optional DI registration; absent → empty, informational-only result).ZB.MOM.WW.ScadaBridge.TemplateEngine— Pre-deploymentSemanticValidatorinvoked insideApplyAsyncbefore the transaction commits. The importer builds a single-templateFlattenedConfigurationdirectly from each importedTemplateDto(no inheritance / composition resolution at design time — the deployment-time flatten revalidates against the full instance graph) and feeds it through the validator alongside aResolvedScriptcatalog combining in-bundle + pre-existing targetSharedScripts. Validator errors are aggregated per template and surfaced as aSemanticValidationExceptionthat rolls back the import transaction.
Interactions
- Central UI — Hosts the Export Bundle (
/design/transport/export) page under the Design nav group and the Import Bundle (/design/transport/import) page under the Admin nav group. The import result page links to the Deployments page and to the filtered Configuration Audit Log Viewer. - Management Service / CLI —
ManagementActorregisters three Transport command handlers (ExportBundleCommand,PreviewBundleCommand,ImportBundleCommand) and the CLI shipsbundle export/bundle preview/bundle importsubcommands. Bundle bytes ride the existing/managementJSON envelope as base64. - Deployment Manager — Never directly invoked by Transport. Transport-driven template changes propagate to deployed instances through the existing revision-hash drift detection in
DeploymentService.CompareAsync; the Deployments page surfaces affected instances as stale automatically. - Security & Auth — Provides
RequireDesignandRequireAdminpolicies fromZB.MOM.WW.ScadaBridge.Security, enforced at both the page and service layers. - Audit Log (Configuration) — Writes
BundleExported/BundleImported/BundleImportFailed/UnencryptedBundleExport/BundleImportUnlockFailedrows viaIAuditService, plus per-import name-resolution warningsBundleImportAlarmScriptUnresolved,BundleImportCompositionUnresolved, andBundleImportBaseTemplateUnresolved; per-entity rows from audited repositories are correlated byBundleImportIdviaIAuditCorrelationContext.
Appendix: Bundle JSON Schema
The manifest.json file is always present in the ZIP root and is never encrypted.
{
"bundleFormatVersion": 1,
"schemaVersion": "1.1",
"createdAtUtc": "2026-05-24T12:34:56Z",
"sourceEnvironment": "dev-cluster-a",
"exportedBy": "alice@corp.example",
"scadaBridgeVersion": "1.4.2",
"contentHash": "sha256:abc123...",
"encryption": {
"algorithm": "AES-256-GCM",
"kdf": "PBKDF2-SHA256",
"iterations": 600000,
"saltB64": "<base64-encoded 16-byte random salt>",
"ivB64": "<base64-encoded 12-byte random IV>"
},
"summary": {
"templates": 12,
"templateFolders": 3,
"sharedScripts": 4,
"externalSystems": 2,
"dbConnections": 1,
"notificationLists": 1,
"smtpConfigs": 0,
"smsConfigs": 0,
"apiMethods": 5,
"sites": 2,
"dataConnections": 3,
"instances": 8
},
"contents": [
{
"type": "Template",
"name": "Pump",
"version": 5,
"dependsOn": ["SharedScript:PumpUtils"]
},
{
"type": "Template",
"name": "Pump.WaterPump",
"version": 3,
"dependsOn": ["Template:Pump", "ExternalSystem:HistorianAPI"]
},
{
"type": "Site",
"name": "site-a",
"version": 1,
"dependsOn": []
},
{
"type": "DataConnection",
"name": "site-a/PlantOpcUa",
"version": 1,
"dependsOn": ["Site:site-a"]
},
{
"type": "Instance",
"name": "WaterPump-01",
"version": 1,
"dependsOn": ["Template:Pump.WaterPump", "Site:site-a"]
}
]
}
Field descriptions:
| Field | Description |
|---|---|
bundleFormatVersion |
Integer. Importer hard-refuses any value higher than what its TransportOptions.SchemaVersionMajor supports. Stays 1 through T18. |
schemaVersion |
Semver string. Minor increments are additive-only and accepted by older importers. T18 bumped this 1.0 → 1.1 (adding the sites/dataConnections/instances content arrays); a pre-T18 importer lists those new entries as "skipped — unsupported in this version" since ManifestValidator gates only on bundleFormatVersion. |
createdAtUtc |
ISO-8601 UTC timestamp of when the export was created. |
sourceEnvironment |
The SourceEnvironment name of the exporting cluster (from TransportOptions). Displayed in the import wizard and required to be retyped at the confirm step. |
exportedBy |
Authenticated username of the person who performed the export. |
scadaBridgeVersion |
Application version of the exporting node. Used for diagnostic display only. |
contentHash |
sha256:<hex> — SHA-256 of the raw content.json or content.enc bytes (pre-encryption). Verified on upload before any decryption. |
encryption |
Present only when a passphrase was supplied. Contains the KDF parameters and the per-bundle random salt and IV needed to re-derive the key and decrypt. Omitted for plaintext bundles. |
encryption.algorithm |
Always "AES-256-GCM" in v1. |
encryption.kdf |
Always "PBKDF2-SHA256" in v1. |
encryption.iterations |
PBKDF2 iteration count. Defaults to 600 000 (configurable via TransportOptions.Pbkdf2Iterations). |
encryption.saltB64 |
Base64-encoded 16-byte random salt generated at export time. |
encryption.ivB64 |
Base64-encoded 12-byte (GCM standard) random IV generated at export time. |
summary |
Artifact count by type, for display in the import wizard's upload step without needing to decrypt content. Schema 1.1 adds sites / dataConnections / instances counts. |
contents |
Ordered list of all artifacts in the bundle. Order is topological (base templates before derived; sites before their connections and instances). Each entry carries the artifact's name (connections use the site-qualified {SiteIdentifier}/{Name}), the schema version at export time, and its direct dependsOn edges for dependency display in the export wizard's Step 2. T18 adds the Site, DataConnection, and Instance entry types. |