# Transport — Bundle Export / Import (Component #24) — Design

**Status:** Approved (brainstorming complete, awaiting implementation plan).
**Author session date:** 2026-05-24.
**Target component number:** #24 (Transport).

---

## 1. Purpose

Provide a file-based, encrypted, environment-agnostic way to **promote configuration artifacts from one ScadaLink cluster to another** (e.g., dev → staging → prod) 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, and applies it. Import is **config-only**: it updates the central configuration database and marks affected instances stale; the user redeploys to sites via the existing Deployments page.

Transport does **not** touch site nodes, does not move runtime state, and does not move site-scoped artifacts.

---

## 2. Location

- New project: `src/ScadaLink.Transport/`
- New tests: `tests/ScadaLink.Transport.Tests/`, `tests/ScadaLink.Transport.IntegrationTests/`
- New design doc: `docs/requirements/Component-Transport.md` (created as part of implementation).
- Central UI pages: `src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor`, `TransportImport.razor`.
- EF migration in `src/ScadaLink.ConfigurationDatabase/Migrations/` (adds `BundleImportId` column to `ConfigurationAuditLog`).

---

## 3. Scope

### 3.1 In scope — transportable artifact groups

| Group | Entities |
|---|---|
| Templates | `Template`, `TemplateAttribute`, `TemplateAlarm`, `TemplateScript`, `TemplateComposition`, `TemplateFolder` |
| System artifacts | `SharedScript`, `ExternalSystemDefinition` + `ExternalSystemMethod`, `DatabaseConnectionDefinition` |
| Central-only | `NotificationList` + recipients, `SmtpConfiguration`, `ApiKey`, `ApiMethod` |

### 3.2 Out of scope (never bundled)

- **Site-scoped:** `Site`, `Area`, `Instance`, `InstanceAttributeOverride`, `InstanceAlarmOverride`, `InstanceConnectionBinding`, `DataConnection`.
- **Operational state:** `DeploymentRecord`, `SystemArtifactDeploymentRecord`, `DeployedConfigSnapshot`, all audit tables, S&F buffers.
- **Cluster identity / auth state:** LDAP mappings, site-scope rules.

Rationale: environments do not share stable site identities (dev "Site-Dev-1" vs. prod "Plant-North"). Migrating instances would require a name-mapping subsystem, deferred until there is concrete user demand.

### 3.3 Non-goals (explicit v1 exclusions)

- Direct cluster-to-cluster pull (file-based only).
- Auto-redeploy on import.
- Bundle signing (separate keypair); manifest SHA-256 is sufficient for v1 tamper detection.
- Differential / incremental bundles — every export is a full snapshot of selected artifacts.
- Server-side bundle library / browsing.

---

## 4. Bundle Format

### 4.1 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.

### 4.2 `manifest.json` (plaintext)

```json
{
  "bundleFormatVersion": 1,
  "schemaVersion": "1.0",
  "createdAtUtc": "2026-05-24T12:34:56Z",
  "sourceEnvironment": "dev-cluster-a",
  "exportedBy": "alice@corp.example",
  "scadaLinkVersion": "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.

### 4.3 `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 UI warns and audit-tags them `UnencryptedBundleExport`.

### 4.4 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 → hard refusal at upload with a clear upgrade message.

---

## 5. Architecture (Option A — new component)

```
ScadaLink.Transport
├── IBundleExporter
│     ExportAsync(ExportSelection, Passphrase?, ct) → Stream
├── IBundleImporter
│     LoadAsync(stream, Passphrase?, ct) → BundleSession
│     PreviewAsync(sessionId, ct)        → ImportPreview
│     ApplyAsync(sessionId, resolutions, ct) → ImportResult
├── DependencyResolver
├── BundleSerializer    (manifest + content JSON; ZIP packer)
├── SecretEncryptor     (AES-256-GCM + PBKDF2)
├── BundleSessionStore  (in-memory, TTL'd)
└── ManifestValidator   (schema/version gating, hash check)
```

- Component is **central-only**. Registered in `ScadaLink.Host` for central roles, never for site roles.
- All persistence goes through **existing audited repository interfaces** in `ScadaLink.ConfigurationDatabase` — no raw `DbContext.SaveChangesAsync` from this component.
- `BundleSessionStore` is in-process on the active central node (matches Blazor Server circuit affinity). 30-minute TTL, GC on expiry, 3-strike passphrase lockout per session.

Rejected alternatives:
- **Option B (extend Template Engine + Deployment Manager):** spreads bundle format and encryption knowledge across components with no shared owner. Cross-artifact dependency resolution has no clean home.
- **Option C (Central-UI-only service):** CLI cannot reuse it (CLI talks to the Management Service). Violates the codebase pattern of keeping UI thin.

---

## 6. Export Flow

### 6.1 UI — 4-step wizard under the Design nav group

**Step 1 — Select artifacts.** Templates rendered as a **tree** matching the existing Templates page (reuse the tree component from `docs/plans/2026-03-23-treeview-component.md`, refactored into a shared `TemplateFolderTree.razor` with a 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 lists — no folder hierarchy in their data model.

**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 def 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.

User can toggle "include all dependencies" off (with warning that the bundle may produce an invalid import).

**Step 3 — Encryption.** Passphrase + confirm. Strength meter. Explicit warning lists every secret field being encrypted. Optional "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.

### 6.2 Backend

```
User (Design role) ─► Central UI Export wizard
                          │
                          ▼
                   IBundleExporter
                          │
                          ├─► DependencyResolver  ─► repositories (read)
                          ├─► EntitySerializer    ─► content.json
                          ├─► SecretEncryptor     ─► content.enc  (if passphrase)
                          ├─► ManifestBuilder     ─► manifest.json
                          ▼
                   ZIP packer → temp file → browser download
                          │
                          ▼
                   IAuditService.LogAsync(BundleExported …)
```

Audit event: `BundleExported` — caller, artifact count, content hash, encrypted yes/no, bundle filename.

Authorization: `RequireDesign` on both the Razor page and `IBundleExporter.ExportAsync` (defense in depth).

---

## 7. Import Flow

### 7.1 UI — 5-step wizard under the Design nav group

**Step 1 — Upload.** Drag-and-drop or browse. On selection, manifest is parsed and displayed (source env, exporter, timestamp, content count, SHA-256, encrypted yes/no). Manifest hash validated against `content` blob.

**Step 2 — Passphrase** (skipped if 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 side-by-side or `+/-/~` 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 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's 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).

**Step 4 — Confirm.** Final summary plus a **"3 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 `BundleImported` audit row, link to Deployments page filtered to the newly stale instances.

### 7.2 Backend

```
User (Admin role) ─► uploads bundle
                          │
                          ▼
                   IBundleImporter.LoadAsync
                     · verify SHA-256 (manifest vs content)
                     · check bundleFormatVersion supported
                     · decrypt content.enc with passphrase (if encrypted)
                     · deserialize entities
                     · open BundleSession (30-min TTL)
                          │
                          ▼
                   PreviewAsync → diff vs target DB → ImportPreview
                          │
                          ▼  (user reviews + resolves conflicts)
                          │
                   ApplyAsync (single EF transaction)
                     · run pre-deployment semantic validator (Template Engine)
                     · apply resolutions (add / overwrite / skip / rename)
                     · upsert TemplateFolder hierarchy
                     · stale-mark affected Instances
                     · IAuditService.LogAsync(BundleImported …)
                     · commit
                          │
                          ▼
                   ImportResult → UI step 5
                          │
                          ▼
                   "View on Deployments →"  (existing page)
```

Authorization: `RequireAdmin` on both the Razor page and `IBundleImporter.*` entrypoints.

### 7.3 Stale-instance signaling

When an applied conflict overwrites a template (or a template composed by other templates), every `Instance` whose direct or composed template was overwritten gets its `DeployedRevisionHash` set to `stale` (reusing the existing revision-drift mechanism in Deployment Manager). The Deployments page already surfaces stale instances and offers redeploy; no new UI is needed.

---

## 8. Error Handling

| Where | Failure | Surfaced as |
|---|---|---|
| Upload | Not a zip / missing `manifest.json` | Step 1 error: "Not a valid ScadaLink bundle" |
| Upload | `bundleFormatVersion` newer than supported | Step 1 error: "Bundle was created by ScadaLink 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.

---

## 9. Security

- **AES-256-GCM** for content encryption with **PBKDF2-SHA256 / 600 000 iterations** (OWASP 2023+ guidance), per-bundle random salt, random IV per encryption. GCM auth tag verified before decryption — wrong passphrase fails cleanly.
- **Passphrase never persisted.** Lives only inside the export/import service call path; 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 Central UI; no new channel.
- **Audit trail is the chain of custody.** Every export, every import (including aborted ones at validation), 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) enforced both on the Razor page and inside `ScadaLink.Transport` service entrypoints. UI is not the only gate.
- **Bundles are not retained server-side** after download (export) or after `ApplyAsync` commits (import).

---

## 10. 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 `ConfigurationAuditLog` row:

| Action during import | `ConfigurationAuditLog` 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` |
| Instance marked stale | `InstanceMarkedStale` (existing event) |

**Correlation:** every per-entity row written during an import carries a new optional `BundleImportId` column (the GUID of the parent `BundleImported` summary row). The existing `ConfigurationAuditLog` Central UI page gains a **Bundle Import** filter that surfaces all rows for a given import. The `BundleImported` summary row links to the filtered view.

**Schema change:** one EF migration adds:

- `BundleImportId uniqueidentifier NULL` on `ConfigurationAuditLog`.
- Non-clustered index `IX_ConfigurationAuditLog_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.

---

## 11. Observability

- Structured log events: `transport.export.completed`, `transport.import.applied`, `transport.import.failed`, `transport.import.unlock_failed`.
- Counters: bundle size (bytes), artifact count, decryption failures per hour, sessions opened/expired.
- New `BundleImported` and `BundleImportFailed` audit row types appear in the existing audit log page filter.

---

## 12. Authorization Summary

| Operation | Required role | Enforced at |
|---|---|---|
| Open Export page | `RequireDesign` | Razor page authorize attribute |
| `IBundleExporter.ExportAsync` | `RequireDesign` | Service entrypoint |
| Open Import page | `RequireAdmin` | Razor page authorize attribute |
| `IBundleImporter.LoadAsync` / `PreviewAsync` / `ApplyAsync` | `RequireAdmin` | Service entrypoint |
| `ConfigurationAuditLog` "Bundle Import" filter | `RequireAdmin` or `Audit` | Existing audit page logic |

---

## 13. CLI (Deferred)

The `ScadaLink.Transport` library is callable from both Razor pages and `ScadaLink.CLI`. CLI commands are **not** built in v1 but the design leaves a clean path:

```
scadalink transport export \
    --templates Pump,Pump.WaterPump \
    --shared-scripts PumpUtils \
    --out bundle.scadabundle \
    --passphrase-file /run/secrets/p

scadalink transport import bundle.scadabundle \
    --passphrase-file /run/secrets/p \
    --on-conflict overwrite|skip|rename \
    --dry-run
```

Same auth model via the Management API.

---

## 14. Test Plan

| Layer | Project | Coverage |
|---|---|---|
| Unit | `tests/ScadaLink.Transport.Tests` (new) | `BundleSerializer` round-trip, `DependencyResolver` topology + cycle handling, `SecretEncryptor` (encrypt → decrypt round-trip, wrong passphrase fails, tampered ciphertext fails GCM auth tag), `ManifestBuilder` schema-version gating, `ImportPreview` diff for each entity type (identical / modified / new), per-conflict resolution merge logic, stale-instance cascade through composed templates |
| Integration | `tests/ScadaLink.Transport.IntegrationTests` (new) | End-to-end against in-memory + real SQL Server: export → import on same DB (no-op idempotency), export → wipe → import (full restore), export → modify-target → import under each conflict resolution, validation failure rolls back cleanly, audit rows correlated by `BundleImportId` |
| UI | `tests/ScadaLink.CentralUI.Tests` | Razor page authorization (`RequireDesign` for export, `RequireAdmin` for import), wizard step navigation, session TTL expiry, tri-state checkbox tree behavior |
| Manual | docker cluster | Cross-cluster: bring up two clusters, export from one, import to the other, verify stale instances surface on Deployments page, redeploy works |

---

## 15. Deployment

- New component registered in `ScadaLink.Host` for central roles only.
- No new ports, no new database (uses existing central MS SQL).
- One EF migration: `BundleImportId` nullable `uniqueidentifier` column on `ConfigurationAuditLog` + supporting index.
- `bash docker/deploy.sh` picks up the change with the standard rebuild + restart — no compose changes.

---

## 16. Documentation Deliverables

- **New:** `docs/requirements/Component-Transport.md` — full component design doc following the standard structure (Purpose, Location, Responsibilities, detailed design sections, Dependencies, Interactions).
- **Updated:** `README.md` component table — add row #24: "Transport — Bundle export/import for templates, shared scripts, external systems, central-only artifacts; AES-256-GCM encryption; per-conflict resolution on import; correlated audit trail."
- **Updated cross-references in:**
  - `Component-TemplateEngine.md` — mention Transport as a consumer of template + composition + script entities.
  - `Component-DeploymentManager.md` — mention stale-instance signaling from Transport.
  - `Component-SecurityAuth.md` — record the role mapping (`RequireDesign` export, `RequireAdmin` import).
  - `Component-ConfigurationDatabase.md` — record the new `BundleImportId` column + index.
  - `Component-CentralUI.md` — record the two new pages under the Design nav group.
- `Component-Transport.md` includes the bundle JSON schema in an appendix for downstream tooling.

---

## 17. Rollout

- Ship in **one PR / branch**: design doc + component code + UI pages + EF migration + tests + README + cross-reference updates.
- Backward compatible — no behavior change for users who never open the new pages.
- **No feature flag** (per `CLAUDE.md` guidance — don't add flags for hypothetical toggling).

---

## 18. Open Questions / Future Work

- **Site-scoped artifact transport** (Instances, Areas, bindings, DataConnections). Requires a name-mapping subsystem (source-env-site → target-env-site). Deferred until concrete demand.
- **Direct cluster-to-cluster pull** as an alternative to file handoff. Same `ScadaLink.Transport` library can back it; needs cross-env auth design.
- **Bundle signing** with an asymmetric keypair (separate from passphrase encryption) for stronger non-repudiation. Manifest content hash is sufficient for v1 tamper detection.
- **CLI commands** (`scadalink transport export/import`). Shape pre-decided in §13; not built in v1.
- **Differential bundles** ("only what changed since last export"). YAGNI for v1.

---

## 19. Brainstorming Decisions Captured

(For traceability to the brainstorming session that produced this doc.)

| Question | Decision |
|---|---|
| Transport model | File-based export/import (.scadabundle) |
| Artifact groups | Templates + composition; system artifacts; central-only — site-scoped excluded |
| Secrets | Encrypted in bundle via passphrase (AES-256-GCM + PBKDF2 600k) |
| Selection granularity | Per-artifact with auto-included dependencies |
| Selection UI | Tree view for templates (reuse Templates-page tree); flat lists for other groups |
| Conflict resolution | Interactive per-artifact diff + Skip / Overwrite / Rename |
| Post-import behavior | Config-only; user redeploys via existing Deployments page |
| Authorization | `Design` to export, `Admin` to import |
| Audit | Per-entity rows via existing audited repositories, correlated by `BundleImportId` |
| Architecture | New component `ScadaLink.Transport` (Option A) |
