# 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 nodes, does not move runtime state, and does not move site-scoped artifacts.

## 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/` (adds `BundleImportId` column to `AuditLogEntries`)
- This design doc: `docs/requirements/Component-Transport.md`

## Responsibilities

- Define and own the `.scadabundle` file 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, API keys, API methods.
- Serialize and deserialize all transportable entity types to/from bundle DTOs, carving secret fields into an isolated `SecretsBlock`.
- 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 `UnencryptedBundleExport` audit event when none is given.
- Validate `manifest.json` on upload: format version gating, SHA-256 content hash verification.
- Manage in-memory `BundleSession` objects: 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, or a Blocker.
- Apply user-supplied conflict resolutions (Add, Overwrite, Skip, Rename) in a single EF transaction, running two-tier semantic validation before committing: a minimal name-resolution scan over the merged target (fails fast on unresolved SharedScript / ExternalSystem identifiers), then the full `SemanticValidator` from `ZB.MOM.WW.ScadaBridge.TemplateEngine` over each imported template's per-template `FlattenedConfiguration`.
- Emit `BundleExported`, `BundleImported`, `BundleImportFailed`, `UnencryptedBundleExport`, `BundleImportUnlockFailed`, `BundleImportAlarmScriptUnresolved`, and `BundleImportCompositionUnresolved` audit events via `IAuditService`.
- Thread a `BundleImportId` correlation GUID through every per-entity `AuditLogEntry` written during `ApplyAsync` via a scoped `IAuditCorrelationContext`.
- Enforce `RequireDesign` on export and `RequireAdmin` on 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)
└── scripts/                   # optional: large script bodies as files
    ├── template-{id}-{name}.cs
    └── shared-{id}-{name}.cs
```

Exactly one of `content.json` or `content.enc` is present.

### `manifest.json` (plaintext)

```json
{
  "bundleFormatVersion": 1,
  "schemaVersion": "1.0",
  "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, "apiKeys": 2, "apiMethods": 5
  },
  "contents": [
    { "type": "Template", "name": "Pump", "version": 5,
      "dependsOn": ["SharedScript:PumpUtils"] },
    { "type": "Template", "name": "Pump.WaterPump", "version": 3,
      "dependsOn": ["Template:Pump", "ExternalSystem:HistorianAPI"] }
  ]
}
```

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`.
- Secret fields (API key hashes, SMTP password, external system credentials, DB connection passwords) live in a nested `secrets` block on each affected entity.
- The whole `content` blob 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.
- `bundleFormatVersion` newer than what the importer supports produces a hard refusal at upload with a clear upgrade message.

## Architecture

```mermaid
%%{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) → BundleSession<br/>PreviewAsync(sessionId, ct) → ImportPreview<br/>ApplyAsync(sessionId, resolutions, ct) → ImportResult"]
        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-only. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles. 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.

## 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, API keys, API methods) are flat checkbox lists.

**Step 2 — Review dependencies.** The resolver expands the user's selection along these edges:

- `Template A` composes `Template B` → include `B`.
- `Template` references `SharedScript` (by name) → include the script.
- `Template` references `ExternalSystem` → include the definition and its methods.
- `ApiMethod` references `SharedScript` → include the script.
- `NotificationList` references `SmtpConfiguration` → include the SMTP config.
- 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

```mermaid
%%{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)

**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 — 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 `+/-/~` line diff. 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).

**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); and (c) filters out a small `KnownNonReferenceNames` denylist of .NET stdlib types (`Convert`, `DateTimeOffset`, `ToString`, `Dispose`, `UtcNow`, …), ScadaBridge runtime API roots (`Notify`, `Database`, `ExternalSystem`, `Scripts`, `Instance`, `Parameters`, `Attributes`, `Route`, …), and common SQL keywords that appear inside string literals (`COUNT`, `SELECT`, `FROM`, …). Both the diff-step `DetectBlockersAsync` and the Apply-time `RunSemanticValidationAsync` Pass 1 share this filter, so the diff preview and the Apply gate agree.

**Step 4 — Confirm.** Final summary plus a "N instances will become stale" warning enumerating affected instances. User types the source environment name to confirm (typo-resistant gate at the prod boundary).

**Step 5 — Result.** Counts, link to the `BundleImported` audit row, link to the Deployments page filtered to the newly stale instances.

### Backend

```mermaid
%%{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 (single EF transaction)<br/>· run two-tier semantic validation<br/>&nbsp;&nbsp;(minimal name scan + full SemanticValidator)<br/>· apply resolutions (add / overwrite / skip / rename)<br/>· upsert TemplateFolder hierarchy<br/>· IAuditService.LogAsync(BundleImported …)<br/>· commit"]
    RESULT["ImportResult → UI step 5"]
    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.

### 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. The import result enumerates affected instances (by computing the hash drift before committing) so Step 4 can display a preview count and Step 5 can link directly to the Deployments page.

## 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` |
| Preview | Bundle references shared script not in bundle and not in target DB | Listed as a blocker row in Step 3; cannot Apply until resolved |
| 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 | Step 3+ 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-session 3-strike lockout; per-IP-per-hour cap (default 10, configurable) to deter brute force against a stolen bundle. Each failed attempt produces a `BundleImportUnlockFailed` audit row.
- **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) and `RequireAdmin` (import) are enforced both on the Razor page and inside `ZB.MOM.WW.ScadaBridge.Transport` service entrypoints. The UI is not the only gate.
- **Bundles are not retained server-side** after download (export) or after `ApplyAsync` commits (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) |

**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 NULL` on `AuditLogEntries`.
- 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.

```bash
scadabridge bundle export  --output FILE --passphrase X [--all | --templates A,B ...] \
                         [--shared-scripts ...] [--external-systems ...] [--db-connections ...] \
                         [--notification-lists ...] [--smtp-configs ...] [--api-keys ...] \
                         [--api-methods ...] [--include-dependencies] [--source-environment NAME]

scadabridge bundle preview --input FILE --passphrase X
                         # prints PreviewBundleResult JSON: per-row items + add/modified/identical/blocker counts

scadabridge bundle import  --input FILE --passphrase X [--on-conflict skip|overwrite|rename]
                         # 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. 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`); transport interface definitions (`IBundleExporter`, `IBundleImporter`, `IBundleSessionStore`, `IAuditCorrelationContext`).
- **`ZB.MOM.WW.ScadaBridge.ConfigurationDatabase`** — All repository implementations and `IAuditService` for persistence and per-entity audit emission; `IAuditCorrelationContext` implementation (`AuditCorrelationContext`) registered as a scoped service; EF migration for `BundleImportId`.
- **`ZB.MOM.WW.ScadaBridge.TemplateEngine`** — Pre-deployment `SemanticValidator` invoked inside `ApplyAsync` before the transaction commits. The importer builds a single-template `FlattenedConfiguration` directly from each imported `TemplateDto` (no inheritance / composition resolution at design time — the deployment-time flatten revalidates against the full instance graph) and feeds it through the validator alongside a `ResolvedScript` catalog combining in-bundle + pre-existing target `SharedScript`s. Validator errors are aggregated per template and surfaced as a `SemanticValidationException` that 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** — `ManagementActor` registers three Transport command handlers (`ExportBundleCommand`, `PreviewBundleCommand`, `ImportBundleCommand`) and the CLI ships `bundle export` / `bundle preview` / `bundle import` subcommands. Bundle bytes ride the existing `/management` JSON 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 `RequireDesign` and `RequireAdmin` policies from `ZB.MOM.WW.ScadaBridge.Security`, enforced at both the page and service layers.
- **Audit Log (Configuration)** — Writes `BundleExported` / `BundleImported` / `BundleImportFailed` / `UnencryptedBundleExport` / `BundleImportUnlockFailed` rows via `IAuditService`, plus per-import name-resolution warnings `BundleImportAlarmScriptUnresolved` and `BundleImportCompositionUnresolved`; per-entity rows from audited repositories are correlated by `BundleImportId` via `IAuditCorrelationContext`.

---

## Appendix: Bundle JSON Schema

The `manifest.json` file is always present in the ZIP root and is never encrypted.

```json
{
  "bundleFormatVersion": 1,
  "schemaVersion": "1.0",
  "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,
    "apiKeys": 2,
    "apiMethods": 5
  },
  "contents": [
    {
      "type": "Template",
      "name": "Pump",
      "version": 5,
      "dependsOn": ["SharedScript:PumpUtils"]
    },
    {
      "type": "Template",
      "name": "Pump.WaterPump",
      "version": 3,
      "dependsOn": ["Template:Pump", "ExternalSystem:HistorianAPI"]
    }
  ]
}
```

**Field descriptions:**

| Field | Description |
|---|---|
| `bundleFormatVersion` | Integer. Importer hard-refuses any value higher than what its `TransportOptions.SchemaVersionMajor` supports. |
| `schemaVersion` | Semver string. Minor increments are additive-only and accepted by older importers. |
| `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. |
| `contents` | Ordered list of all artifacts in the bundle. Order is topological (base templates before derived). Each entry carries the artifact's name, the schema `version` at export time, and its direct `dependsOn` edges for dependency display in the export wizard's Step 2. |
