# Transport (Component #24) Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

**Goal:** Ship Component #24 (Transport) — file-based, encrypted bundle export/import of templates, system artifacts, and central-only configuration via the Central UI — as a single shipping slice.

**Architecture:** New `ZB.MOM.WW.ScadaBridge.Transport` project peer to Template Engine + Deployment Manager. Two new Central UI pages under the Design nav group. Persistence flows through existing audited repositories with a new scoped `IAuditCorrelationContext` carrying `BundleImportId`. AES-256-GCM + PBKDF2-SHA256 (600 000 iterations) for content encryption. No site changes.

**Tech Stack:** .NET 10, EF Core, ASP.NET Core / Blazor Server, xUnit + NSubstitute + FluentAssertions, in-memory EF for integration tests, existing `TreeView.razor` component extended with a checkbox-selection mode.

**Source design doc:** `docs/plans/2026-05-24-transport-design.md` (already committed).

**Key codebase facts that shape this plan:**
- Audit entity is `AuditLogEntry` (`src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/AuditLogEntry.cs`), DbSet `_context.AuditLogEntries`.
- `IAuditService.LogAsync(user, action, entityType, entityId, entityName, afterState, ct)` — no existing correlation parameter; we add a scoped `IAuditCorrelationContext` rather than changing every repository signature.
- **No persisted `DeployedRevisionHash` on Instance.** `DeploymentService.CompareAsync` computes stale-vs-fresh live by comparing `snapshot.RevisionHash` to the current flattened-config hash. Overwriting a template naturally changes the hash, so the Deployments page surfaces affected instances automatically — **no explicit stale-mark write is required.**
- `TreeView.razor` exists at `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor` with single-select navigation. We extend it with a checkbox / multi-select mode rather than building a parallel widget.
- DI extension convention: `AddTransport()` in `ServiceCollectionExtensions.cs`, registered in `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` under "Central-only components".
- Test integration pattern: `ScadaBridgeWebApplicationFactory : WebApplicationFactory<Program>` with in-memory EF.

**No feature flag** — backward compatible additive change; users who never open the new pages see no behavior difference.

---

## Task Index

| # | Task | Class | Time |
|---|---|---|---|
| 0 | Bundle DTOs in Commons | small | ~3 min |
| 1 | Transport interfaces | small | ~3 min |
| 2 | `BundleImportId` on `AuditLogEntry` + EF Fluent config | small | ~3 min |
| 3 | EF migration `AddBundleImportIdToAuditLog` | trivial | ~2 min |
| 4 | `IAuditCorrelationContext` scoped service | small | ~3 min |
| 5 | `AuditService` reads correlation context | small | ~3 min |
| 6 | `ZB.MOM.WW.ScadaBridge.Transport` project skeleton + slnx + DI | small | ~3 min |
| 7 | `TransportOptions` | trivial | ~2 min |
| 8 | `BundleSecretEncryptor` (AES-256-GCM + PBKDF2) + tests | standard | ~4 min |
| 9 | `ManifestBuilder` + manifest schema validation | standard | ~4 min |
| 10 | `EntitySerializer` — DTOs + secret carving | standard | ~5 min |
| 11 | `BundleSerializer` (ZIP packer + reader + content hash) | standard | ~4 min |
| 12 | `DependencyResolver` + topology tests | standard | ~5 min |
| 13 | `BundleSessionStore` (TTL + 3-strike lockout) | standard | ~4 min |
| 14 | `BundleExporter.ExportAsync` + audit + tests | standard | ~5 min |
| 15 | `BundleImporter.LoadAsync` | standard | ~4 min |
| 16 | `BundleImporter.PreviewAsync` (diff engine) | standard | ~5 min |
| 17 | `BundleImporter.ApplyAsync` (transaction + validation + audit) | high-risk | ~5 min |
| 18 | Host registration + transport options binding | trivial | ~2 min |
| 19 | `TreeView` checkbox-selection mode + tri-state propagation | standard | ~5 min |
| 20 | `TemplateFolderTree.razor` wrapper + Templates.razor refactor | standard | ~4 min |
| 21 | `TransportExport.razor` wizard | high-risk | ~5 min |
| 22 | `TransportImport.razor` wizard | high-risk | ~5 min |
| 23 | NavMenu entries + UI auth attributes verified | small | ~3 min |
| 24 | Audit log "Bundle Import" filter on existing page | small | ~3 min |
| 25 | Integration: export → wipe → import round-trip | standard | ~5 min |
| 26 | Integration: conflict resolutions + rollback on validation fail | standard | ~5 min |
| 27 | `Component-Transport.md` design doc | small | ~3 min |
| 28 | README + cross-reference updates | small | ~3 min |
| 29 | Manual cluster verification checklist | trivial | ~3 min |

---

## Task 0: Bundle DTOs in Commons

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 1, Task 2, Task 4, Task 19

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleManifest.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSummary.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/EncryptionMetadata.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ManifestContentEntry.cs`

**Step 1: Write the DTOs**

`BundleManifest.cs`:
```csharp
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;

public sealed record BundleManifest(
    int BundleFormatVersion,
    string SchemaVersion,
    DateTimeOffset CreatedAtUtc,
    string SourceEnvironment,
    string ExportedBy,
    string ScadaBridgeVersion,
    string ContentHash,
    EncryptionMetadata? Encryption,
    BundleSummary Summary,
    IReadOnlyList<ManifestContentEntry> Contents);
```

`EncryptionMetadata.cs`:
```csharp
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;

public sealed record EncryptionMetadata(
    string Algorithm,      // "AES-256-GCM"
    string Kdf,            // "PBKDF2-SHA256"
    int Iterations,
    string SaltB64,
    string IvB64);
```

`BundleSummary.cs`:
```csharp
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;

public sealed record BundleSummary(
    int Templates,
    int TemplateFolders,
    int SharedScripts,
    int ExternalSystems,
    int DbConnections,
    int NotificationLists,
    int SmtpConfigs,
    int ApiKeys,
    int ApiMethods);
```

`ManifestContentEntry.cs`:
```csharp
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;

public sealed record ManifestContentEntry(
    string Type,
    string Name,
    int Version,
    IReadOnlyList<string> DependsOn);
```

**Step 2: Build**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj`
Expected: build succeeds.

**Step 3: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/
git commit -m "feat(transport): add bundle manifest DTOs in Commons"
```

---

## Task 1: Transport interfaces

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 0, Task 2, Task 4, Task 19

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ExportSelection.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ImportPreview.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ImportResolution.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ImportResult.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSession.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleExporter.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleImporter.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleSessionStore.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IAuditCorrelationContext.cs`

**Step 1: Write the types and interfaces**

`ExportSelection.cs`:
```csharp
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;

public sealed record ExportSelection(
    IReadOnlyList<int> TemplateIds,
    IReadOnlyList<int> SharedScriptIds,
    IReadOnlyList<int> ExternalSystemIds,
    IReadOnlyList<int> DatabaseConnectionIds,
    IReadOnlyList<int> NotificationListIds,
    IReadOnlyList<int> SmtpConfigurationIds,
    IReadOnlyList<int> ApiKeyIds,
    IReadOnlyList<int> ApiMethodIds,
    bool IncludeDependencies);
```

`ImportPreview.cs`:
```csharp
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;

public enum ConflictKind { Identical, Modified, New, Blocker }

public sealed record ImportPreviewItem(
    string EntityType,
    string Name,
    int? ExistingVersion,
    int? IncomingVersion,
    ConflictKind Kind,
    string? FieldDiffJson,
    string? BlockerReason);

public sealed record ImportPreview(
    Guid SessionId,
    IReadOnlyList<ImportPreviewItem> Items);
```

`ImportResolution.cs`:
```csharp
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;

public enum ResolutionAction { Add, Overwrite, Skip, Rename }

public sealed record ImportResolution(
    string EntityType,
    string Name,
    ResolutionAction Action,
    string? RenameTo);
```

`ImportResult.cs`:
```csharp
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;

public sealed record ImportResult(
    Guid BundleImportId,
    int Added,
    int Overwritten,
    int Skipped,
    int Renamed,
    IReadOnlyList<int> StaleInstanceIds,
    string AuditEventCorrelation);
```

`BundleSession.cs`:
```csharp
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;

public sealed class BundleSession
{
    public Guid SessionId { get; init; }
    public BundleManifest Manifest { get; init; } = null!;
    public byte[] DecryptedContent { get; init; } = Array.Empty<byte>();
    public DateTimeOffset ExpiresAt { get; init; }
    public int FailedUnlockAttempts { get; set; }
    public bool Locked => FailedUnlockAttempts >= 3;
}
```

`IBundleExporter.cs`:
```csharp
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;

namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;

public interface IBundleExporter
{
    Task<Stream> ExportAsync(
        ExportSelection selection,
        string user,
        string sourceEnvironment,
        string? passphrase,
        CancellationToken cancellationToken = default);
}
```

`IBundleImporter.cs`:
```csharp
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;

namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;

public interface IBundleImporter
{
    Task<BundleSession> LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default);
    Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default);
    Task<ImportResult> ApplyAsync(
        Guid sessionId,
        IReadOnlyList<ImportResolution> resolutions,
        string user,
        CancellationToken ct = default);
}
```

`IBundleSessionStore.cs`:
```csharp
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;

namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;

public interface IBundleSessionStore
{
    BundleSession Open(BundleSession session);
    BundleSession? Get(Guid sessionId);
    void Remove(Guid sessionId);
    void EvictExpired();
}
```

`IAuditCorrelationContext.cs`:
```csharp
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;

/// <summary>
/// Scoped service the bundle importer sets to thread a BundleImportId through to
/// the audit log entries emitted by the audited repository methods invoked during
/// ApplyAsync. AuditService reads this and stamps every AuditLogEntry it writes.
/// </summary>
public interface IAuditCorrelationContext
{
    Guid? BundleImportId { get; set; }
}
```

**Step 2: Build**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj`
Expected: build succeeds.

**Step 3: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/
git commit -m "feat(transport): add IBundleExporter / IBundleImporter interfaces"
```

---

## Task 2: `BundleImportId` on `AuditLogEntry` + EF Fluent config

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 0, Task 1, Task 19

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/AuditLogEntry.cs` (add nullable `BundleImportId`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs` (add Fluent config in `OnModelCreating` near the existing `AuditLogEntry` mapping; add `IX_AuditLogEntries_BundleImportId` non-clustered index)

**Step 1: Add property**

In `AuditLogEntry.cs`, after `Timestamp`:
```csharp
public Guid? BundleImportId { get; set; }
```

**Step 2: Add Fluent config + index**

In `ScadaBridgeDbContext.OnModelCreating`, find the existing `modelBuilder.Entity<AuditLogEntry>(...)` block and add:
```csharp
e.HasIndex(x => x.BundleImportId).HasDatabaseName("IX_AuditLogEntries_BundleImportId");
```

If no existing block, add one near other audit configs.

**Step 3: Build**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj`
Expected: build succeeds.

**Step 4: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/AuditLogEntry.cs \
        src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs
git commit -m "feat(transport): add BundleImportId column on AuditLogEntry"
```

---

## Task 3: EF migration `AddBundleImportIdToAuditLog`

**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** none (depends on Task 2)

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/<timestamp>_AddBundleImportIdToAuditLog.cs` (generated)
- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/<timestamp>_AddBundleImportIdToAuditLog.Designer.cs` (generated)

**Step 1: Generate**

Run:
```bash
dotnet ef migrations add AddBundleImportIdToAuditLog \
    -p src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \
    -s src/ZB.MOM.WW.ScadaBridge.Host
```
Expected: two files generated under `Migrations/` with naming pattern `yyyyMMddHHmmss_AddBundleImportIdToAuditLog.cs`.

**Step 2: Sanity-check the migration**

Open the generated `.cs` file. It must:
- Call `AddColumn<Guid>(...)` for `BundleImportId` with `nullable: true`.
- Call `CreateIndex(...)` for `IX_AuditLogEntries_BundleImportId`.

If the generated migration includes anything else (drift from other entity changes), abort and surface it — that's a plan defect.

**Step 3: Build**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj`
Expected: build succeeds.

**Step 4: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/
git commit -m "feat(transport): EF migration AddBundleImportIdToAuditLog"
```

---

## Task 4: `IAuditCorrelationContext` scoped service

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 0, Task 1, Task 2, Task 19

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Services/AuditCorrelationContext.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs` (add scoped registration; if no file, create alongside existing convention used by audit service)

**Step 1: Write the implementation**

`AuditCorrelationContext.cs`:
```csharp
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;

namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;

/// <summary>
/// Per-scope mutable holder for the active bundle import id. AuditService reads it
/// while writing AuditLogEntry rows. Registered as Scoped so each Blazor circuit /
/// request gets its own value; ApplyAsync explicitly creates a service scope and
/// sets the id at the top of the transaction.
/// </summary>
public sealed class AuditCorrelationContext : IAuditCorrelationContext
{
    public Guid? BundleImportId { get; set; }
}
```

**Step 2: Register**

In `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs` (the `AddConfigurationDatabase()` method), add:
```csharp
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
```
If a `ServiceCollectionExtensions.cs` doesn't exist in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase`, look at where `IAuditService` is currently registered (search the repo for `AddScoped<IAuditService`) and add the registration adjacent to it.

**Step 3: Build**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj`
Expected: build succeeds.

**Step 4: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Services/AuditCorrelationContext.cs \
        src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs
git commit -m "feat(transport): add IAuditCorrelationContext scoped service"
```

---

## Task 5: `AuditService` reads correlation context

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (depends on Tasks 2 + 4)

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Services/AuditService.cs`
- Modify: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/AuditServiceTests.cs` (if exists) OR create `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/AuditServiceTests.cs`

**Step 1: Write the failing test first**

Add test that sets `IAuditCorrelationContext.BundleImportId`, calls `IAuditService.LogAsync`, asserts the emitted `AuditLogEntry.BundleImportId` matches. Test that with `BundleImportId = null`, the column is null.

**Step 2: Run to verify it fails**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/`
Expected: FAIL (correlation not yet wired through).

**Step 3: Implement**

In `AuditService.cs`:
1. Inject `IAuditCorrelationContext` via constructor.
2. In `LogAsync`, set `entry.BundleImportId = _correlationContext.BundleImportId;` right after constructing `entry`.

**Step 4: Run tests**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/`
Expected: PASS.

**Step 5: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Services/AuditService.cs \
        tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/
git commit -m "feat(transport): AuditService stamps BundleImportId from correlation context"
```

---

## Task 6: `ZB.MOM.WW.ScadaBridge.Transport` project skeleton

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (subsequent tasks depend on the project existing)

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj`
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs`
- Modify: `ZB.MOM.WW.ScadaBridge.slnx` (add `<Project Path="src/ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj" />` in alphabetical position)
- Create: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/ZB.MOM.WW.ScadaBridge.Transport.Tests.csproj`
- Create: `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.csproj`
- Modify: `ZB.MOM.WW.ScadaBridge.slnx` (add the two test projects)

**Step 1: Write the csproj**

`ZB.MOM.WW.ScadaBridge.Transport.csproj`:
```xml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
    <ProjectReference Include="../ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj" />
    <ProjectReference Include="../ZB.MOM.WW.ScadaBridge.TemplateEngine/ZB.MOM.WW.ScadaBridge.TemplateEngine.csproj" />
  </ItemGroup>
  <ItemGroup>
    <InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.Transport.Tests" />
    <InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests" />
  </ItemGroup>
</Project>
```

`ServiceCollectionExtensions.cs`:
```csharp
using Microsoft.Extensions.DependencyInjection;

namespace ZB.MOM.WW.ScadaBridge.Transport;

public static class ServiceCollectionExtensions
{
    public const string OptionsSection = "ScadaBridge:Transport";

    public static IServiceCollection AddTransport(this IServiceCollection services)
    {
        ArgumentNullException.ThrowIfNull(services);
        services.AddOptions<TransportOptions>().BindConfiguration(OptionsSection);
        // Concrete services added in later tasks.
        return services;
    }
}
```

Test csprojs follow the existing test pattern (look at `tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.csproj` for shape — xUnit + NSubstitute + FluentAssertions + ProjectReference back to ZB.MOM.WW.ScadaBridge.Transport).

**Step 2: Add to slnx**

Insert in alphabetical order under `/src/` and `/tests/` folder sections.

**Step 3: Build**

Run: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`
Expected: build succeeds (Transport empty + tests empty).

**Step 4: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.Transport/ tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/ tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ ZB.MOM.WW.ScadaBridge.slnx
git commit -m "feat(transport): scaffold ZB.MOM.WW.ScadaBridge.Transport project + test projects"
```

---

## Task 7: `TransportOptions`

**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 8 (different file)

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptions.cs`

**Step 1: Write the options class**

```csharp
namespace ZB.MOM.WW.ScadaBridge.Transport;

public sealed class TransportOptions
{
    public int BundleSessionTtlMinutes { get; set; } = 30;
    public int MaxBundleSizeMb { get; set; } = 100;
    public int MaxUnlockAttemptsPerSession { get; set; } = 3;
    public int MaxUnlockAttemptsPerIpPerHour { get; set; } = 10;
    public int Pbkdf2Iterations { get; set; } = 600_000;
    public int SchemaVersionMajor { get; set; } = 1;
}
```

**Step 2: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptions.cs
git commit -m "feat(transport): add TransportOptions"
```

---

## Task 8: `BundleSecretEncryptor` (AES-256-GCM + PBKDF2)

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 7, Task 9, Task 10, Task 12, Task 13, Task 19

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/Encryption/BundleSecretEncryptor.cs`
- Create: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/BundleSecretEncryptorTests.cs`

**Step 1: Write failing tests first**

Tests to write (all in `BundleSecretEncryptorTests.cs`):
1. `Encrypt_then_Decrypt_roundtrips_arbitrary_bytes`
2. `Decrypt_with_wrong_passphrase_throws_CryptographicException`
3. `Decrypt_with_tampered_ciphertext_throws_CryptographicException` (flip one byte in ciphertext)
4. `Encrypt_produces_distinct_ciphertext_for_same_input_due_to_random_iv`
5. `Encrypt_emits_metadata_matching_decryption_inputs` (salt, iv, iterations, algorithm)

**Step 2: Run tests to verify failure**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/ --filter FullyQualifiedName~BundleSecretEncryptorTests`
Expected: FAIL (class not yet defined).

**Step 3: Implement**

```csharp
using System.Security.Cryptography;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;

namespace ZB.MOM.WW.ScadaBridge.Transport.Encryption;

public sealed class BundleSecretEncryptor
{
    private const int KeyBytes = 32;            // AES-256
    private const int SaltBytes = 16;
    private const int NonceBytes = 12;          // GCM standard
    private const int TagBytes = 16;

    public (byte[] Ciphertext, EncryptionMetadata Metadata) Encrypt(
        ReadOnlySpan<byte> plaintext,
        string passphrase,
        int iterations)
    {
        var salt = RandomNumberGenerator.GetBytes(SaltBytes);
        var nonce = RandomNumberGenerator.GetBytes(NonceBytes);
        var key = DeriveKey(passphrase, salt, iterations);

        var ciphertext = new byte[plaintext.Length];
        var tag = new byte[TagBytes];
        using var aes = new AesGcm(key, TagBytes);
        aes.Encrypt(nonce, plaintext, ciphertext, tag);

        // Format: ciphertext || tag
        var output = new byte[ciphertext.Length + TagBytes];
        Buffer.BlockCopy(ciphertext, 0, output, 0, ciphertext.Length);
        Buffer.BlockCopy(tag, 0, output, ciphertext.Length, TagBytes);

        return (output, new EncryptionMetadata(
            "AES-256-GCM", "PBKDF2-SHA256", iterations,
            Convert.ToBase64String(salt),
            Convert.ToBase64String(nonce)));
    }

    public byte[] Decrypt(ReadOnlySpan<byte> payload, EncryptionMetadata metadata, string passphrase)
    {
        if (metadata.Algorithm != "AES-256-GCM" || metadata.Kdf != "PBKDF2-SHA256")
            throw new CryptographicException("Unsupported bundle encryption parameters.");

        var salt = Convert.FromBase64String(metadata.SaltB64);
        var nonce = Convert.FromBase64String(metadata.IvB64);
        var key = DeriveKey(passphrase, salt, metadata.Iterations);

        if (payload.Length < TagBytes) throw new CryptographicException("Bundle payload too short.");
        var ctLen = payload.Length - TagBytes;
        var ciphertext = payload[..ctLen];
        var tag = payload[ctLen..];

        var plaintext = new byte[ctLen];
        using var aes = new AesGcm(key, TagBytes);
        aes.Decrypt(nonce, ciphertext, tag, plaintext);
        return plaintext;
    }

    private static byte[] DeriveKey(string passphrase, byte[] salt, int iterations)
    {
        using var pbkdf2 = new Rfc2898DeriveBytes(passphrase, salt, iterations, HashAlgorithmName.SHA256);
        return pbkdf2.GetBytes(KeyBytes);
    }
}
```

**Step 4: Run tests**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/ --filter FullyQualifiedName~BundleSecretEncryptorTests`
Expected: PASS.

**Step 5: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.Transport/Encryption/ tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/BundleSecretEncryptorTests.cs
git commit -m "feat(transport): AES-256-GCM + PBKDF2 BundleSecretEncryptor"
```

---

## Task 9: `ManifestBuilder` + schema validation

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 7, Task 8, Task 10, Task 12, Task 13, Task 19

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/ManifestBuilder.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/ManifestValidator.cs`
- Create: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/ManifestBuilderTests.cs`

**Step 1: Write failing tests**

Tests:
1. `Build_populates_summary_from_contents`
2. `Build_serializes_to_valid_json`
3. `Validate_rejects_unsupported_bundleFormatVersion`
4. `Validate_rejects_when_contentHash_mismatch`
5. `Validate_accepts_well_formed_v1_manifest`

**Step 2-5:** Run-fail → implement → run-pass → commit.

`ManifestBuilder` accepts: `sourceEnvironment, exportedBy, scadaBridgeVersion, encryption?, contents[], contentBytes` and returns a `BundleManifest` with `ContentHash = SHA-256(contentBytes)`.

`ManifestValidator.Validate(BundleManifest manifest, byte[] contentBytes)` returns a `ValidationResult` enum (`Ok | UnsupportedFormatVersion | ContentHashMismatch | MalformedManifest`).

**Commit:**
```bash
git commit -m "feat(transport): ManifestBuilder + ManifestValidator with schema-version gating"
```

---

## Task 10: `EntitySerializer` — DTOs + secret carving

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 7, Task 8, Task 9, Task 12, Task 13, Task 19

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs` (all bundle DTOs: `TemplateDto`, `TemplateFolderDto`, `SharedScriptDto`, `ExternalSystemDto`, `DatabaseConnectionDto`, `NotificationListDto`, `SmtpConfigDto`, `ApiKeyDto`, `ApiMethodDto`, each with a nested `SecretsBlock` where applicable)
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs`
- Create: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/EntitySerializerTests.cs`

**Step 1: Write failing tests**

Tests:
1. `ToDto_carves_external_system_credentials_into_secrets_block`
2. `ToDto_carves_smtp_password_into_secrets_block`
3. `ToDto_carves_api_key_hash_into_secrets_block`
4. `Roundtrip_template_preserves_attributes_alarms_scripts_composition`
5. `Roundtrip_template_folder_preserves_hierarchy`

**Step 3: Implement**

DTOs are flat records mirroring the Commons POCOs minus EF-only navigation properties. Secrets fields move into a nested `SecretsBlock` record on the DTO:
```csharp
public sealed record ExternalSystemDto(
    string Name,
    string BaseUrl,
    string AuthType,
    IReadOnlyList<ExternalSystemMethodDto> Methods,
    SecretsBlock? Secrets);

public sealed record SecretsBlock(IReadOnlyDictionary<string, string> Values);
```

`EntitySerializer` exposes:
- `BundleContentDto ToBundleContent(EntityAggregate aggregate)` — converts in-memory aggregate to top-level DTO grouped by type.
- `EntityAggregate FromBundleContent(BundleContentDto content)` — inverse.

`BundleContentDto`:
```csharp
public sealed record BundleContentDto(
    IReadOnlyList<TemplateFolderDto> TemplateFolders,
    IReadOnlyList<TemplateDto> Templates,
    IReadOnlyList<SharedScriptDto> SharedScripts,
    IReadOnlyList<ExternalSystemDto> ExternalSystems,
    IReadOnlyList<DatabaseConnectionDto> DatabaseConnections,
    IReadOnlyList<NotificationListDto> NotificationLists,
    IReadOnlyList<SmtpConfigDto> SmtpConfigs,
    IReadOnlyList<ApiKeyDto> ApiKeys,
    IReadOnlyList<ApiMethodDto> ApiMethods);
```

**Step 5: Commit**
```bash
git commit -m "feat(transport): bundle entity DTOs + secret carving in EntitySerializer"
```

---

## Task 11: `BundleSerializer` (ZIP packer + reader + content hash)

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (depends on Tasks 8, 9, 10)

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/BundleSerializer.cs`
- Create: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/BundleSerializerTests.cs`

**Step 1: Write failing tests**

Tests:
1. `Pack_emits_manifest_and_content_entries`
2. `Pack_encrypts_content_when_passphrase_supplied`
3. `Pack_leaves_content_plaintext_when_no_passphrase`
4. `Roundtrip_through_temp_stream_recovers_identical_content`
5. `Read_rejects_zip_without_manifest`

**Step 3: Implement**

`BundleSerializer` uses `System.IO.Compression.ZipArchive`:
- `Stream Pack(BundleContentDto content, ManifestContext ctx, string? passphrase)` — serializes `content` → JSON bytes → optional encrypt → writes `manifest.json` + (`content.json` OR `content.enc`) to a `MemoryStream`-backed ZIP.
- `(BundleManifest manifest, byte[] contentBytes) ReadHeader(Stream zipStream)` — opens, reads `manifest.json` only.
- `BundleContentDto Unpack(Stream zipStream, BundleManifest manifest, string? passphrase)` — full unpack + decrypt.

**Step 5: Commit**
```bash
git commit -m "feat(transport): BundleSerializer ZIP packer/reader"
```

---

## Task 12: `DependencyResolver`

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 7, Task 8, Task 9, Task 10, Task 13, Task 19

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs`
- Create: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/DependencyResolverTests.cs`

**Step 1: Write failing tests**

Tests:
1. `Resolve_includes_base_template_for_composed_template`
2. `Resolve_includes_shared_script_referenced_by_template`
3. `Resolve_includes_external_system_referenced_by_template`
4. `Resolve_includes_api_method_shared_script_dependency`
5. `Resolve_handles_diamond_dependency_without_duplication`
6. `Resolve_includes_template_folder_for_each_selected_template`
7. `Resolve_returns_topological_order_base_before_derived`

**Step 3: Implement**

`DependencyResolver` takes `ExportSelection` + repository readers, expands the selection through the edges in design §6.1, and returns a `ResolvedExport` aggregate:
```csharp
public sealed record ResolvedExport(
    IReadOnlyList<TemplateFolder> TemplateFolders,
    IReadOnlyList<Template> Templates,
    IReadOnlyList<SharedScript> SharedScripts,
    // ... one list per entity type
    IReadOnlyList<ManifestContentEntry> ContentManifest); // for the manifest
```

Use Kahn's algorithm for topological ordering of templates (base before derived). Cycle detection — throw `InvalidOperationException` (templates are acyclic by Template Engine invariant, but defensive).

**Step 5: Commit**
```bash
git commit -m "feat(transport): DependencyResolver with topological closure"
```

---

## Task 13: `BundleSessionStore`

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 7, Task 8, Task 9, Task 10, Task 12, Task 19

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionStore.cs`
- Create: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/BundleSessionStoreTests.cs`

**Step 1: Write failing tests**

Tests:
1. `Open_then_Get_returns_session`
2. `Get_after_TTL_returns_null`
3. `Remove_evicts_session`
4. `EvictExpired_removes_all_past_ttl`
5. `Three_failed_unlock_attempts_locks_session` (Locked == true)

**Step 3: Implement**

Backed by `ConcurrentDictionary<Guid, BundleSession>`. Constructor takes `IOptions<TransportOptions>` and `TimeProvider` for testable expiry. Implements `IBundleSessionStore`.

**Step 5: Commit**
```bash
git commit -m "feat(transport): in-memory BundleSessionStore with TTL + lockout"
```

---

## Task 14: `BundleExporter.ExportAsync`

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Tasks 8–12)

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/Export/BundleExporter.cs`
- Create: `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/BundleExporterTests.cs` (integration — needs DB)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs` (register `IBundleExporter -> BundleExporter`)

**Step 1: Write failing integration test**

Test against in-memory EF:
```csharp
[Fact]
public async Task ExportAsync_writes_audit_event_and_returns_valid_bundle()
```
Seeds two templates with composition + a shared script. Calls `ExportAsync` with selection={Template1Id}, includeDependencies=true. Asserts:
- Returned stream is non-empty and is a valid zip.
- A `BundleExported` audit row exists with the user, content hash, encrypted=false.
- Manifest summary lists 2 templates + 1 shared script.

**Step 3: Implement**

`BundleExporter.ExportAsync`:
1. Resolve dependencies via `DependencyResolver`.
2. Convert to `BundleContentDto` via `EntitySerializer`.
3. Serialize content to JSON bytes.
4. Optionally encrypt with `BundleSecretEncryptor`.
5. Build manifest via `ManifestBuilder`.
6. Pack via `BundleSerializer`.
7. Emit audit row: `IAuditService.LogAsync(user, "BundleExported", "Bundle", manifest.ContentHash, manifest.SourceEnvironment, manifest)`.
8. `await _context.SaveChangesAsync(ct)`.
9. Return stream.

Register in `ServiceCollectionExtensions.AddTransport`:
```csharp
services.AddScoped<IBundleExporter, BundleExporter>();
services.AddSingleton<BundleSecretEncryptor>();
services.AddSingleton<ManifestBuilder>();
services.AddSingleton<ManifestValidator>();
services.AddSingleton<BundleSerializer>();
services.AddSingleton<EntitySerializer>();
services.AddScoped<DependencyResolver>();
```

**Step 5: Commit**
```bash
git commit -m "feat(transport): BundleExporter with audit logging"
```

---

## Task 15: `BundleImporter.LoadAsync`

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (depends on Tasks 11, 13)

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs`
- Create: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/BundleImporterLoadTests.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs`

**Step 1: Write failing tests**

Tests:
1. `LoadAsync_returns_session_for_well_formed_bundle`
2. `LoadAsync_rejects_unsupported_bundleFormatVersion_with_clear_message`
3. `LoadAsync_rejects_content_hash_mismatch`
4. `LoadAsync_rejects_missing_manifest`
5. `LoadAsync_decrypts_when_passphrase_correct`
6. `LoadAsync_throws_CryptographicException_on_wrong_passphrase` (caller increments lockout counter)

**Step 3: Implement**

Logic:
1. Open zip.
2. `BundleSerializer.ReadHeader` → manifest + content bytes.
3. `ManifestValidator.Validate` → reject on mismatch.
4. If manifest has encryption metadata: passphrase required; decrypt content bytes.
5. Deserialize content to `BundleContentDto`.
6. Create `BundleSession { SessionId = Guid.NewGuid(), Manifest, DecryptedContent, ExpiresAt = now + TTL }` → `_sessionStore.Open(session)`.

Register in DI:
```csharp
services.AddScoped<IBundleImporter, BundleImporter>();
services.AddSingleton<IBundleSessionStore, BundleSessionStore>();
```

**Step 5: Commit**
```bash
git commit -m "feat(transport): BundleImporter.LoadAsync with manifest validation"
```

---

## Task 16: `BundleImporter.PreviewAsync` (diff engine)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Task 15)

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs`
- Create: `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/BundleImporterPreviewTests.cs`

**Step 1: Write failing integration tests**

Tests against in-memory EF:
1. `PreviewAsync_classifies_artifact_as_Identical_when_fields_match`
2. `PreviewAsync_classifies_artifact_as_Modified_with_field_diff`
3. `PreviewAsync_classifies_artifact_as_New_when_absent_from_target`
4. `PreviewAsync_emits_Blocker_when_required_dependency_is_missing_in_bundle_and_target`

**Step 3: Implement**

`ArtifactDiff.Compare(string entityType, object incoming, object? existing) → (ConflictKind, string? fieldDiffJson)`. Uses reflection over the DTO's properties; emits a per-field `+/-/~` summary in JSON format. For script bodies, emits a line-based diff (use a simple Myers-style diff implemented in-project; ~80 LOC). No third-party diff library.

`PreviewAsync` walks each top-level DTO list in `session.DecryptedContent`, looks up existing by name via repositories, calls `ArtifactDiff.Compare`, builds `ImportPreviewItem`s. Blocker items appear when a referenced shared script / external system is neither in the bundle nor pre-existing in the target.

**Step 5: Commit**
```bash
git commit -m "feat(transport): BundleImporter.PreviewAsync diff engine"
```

---

## Task 17: `BundleImporter.ApplyAsync` (transaction + validation + audit)

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Task 16)

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs`
- Create: `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/BundleImporterApplyTests.cs`

**Step 1: Write failing integration tests**

Tests:
1. `ApplyAsync_adds_new_artifacts_in_single_transaction`
2. `ApplyAsync_overwrites_artifact_when_resolution_is_Overwrite`
3. `ApplyAsync_skips_artifact_when_resolution_is_Skip`
4. `ApplyAsync_renames_artifact_when_resolution_is_Rename`
5. `ApplyAsync_rolls_back_all_changes_when_semantic_validation_fails`
6. `ApplyAsync_writes_BundleImportId_on_every_emitted_audit_row` (the correlation guarantee)
7. `ApplyAsync_writes_BundleImported_summary_row_inside_transaction`
8. `ApplyAsync_writes_BundleImportFailed_outside_rolled_back_transaction`

**Step 3: Implement**

Flow:
```csharp
public async Task<ImportResult> ApplyAsync(Guid sessionId, IReadOnlyList<ImportResolution> resolutions, string user, CancellationToken ct)
{
    var session = _sessionStore.Get(sessionId) ?? throw new InvalidOperationException("Session expired");
    var bundleImportId = Guid.NewGuid();
    _correlation.BundleImportId = bundleImportId;

    var content = DeserializeContent(session.DecryptedContent);
    var resolutionMap = resolutions.ToDictionary(r => (r.EntityType, r.Name));

    await using var tx = await _context.Database.BeginTransactionAsync(ct);
    try
    {
        // Apply in dependency order: folders → templates (base→derived) → shared scripts
        //                          → external systems → db conn → notification lists
        //                          → smtp configs → api keys → api methods
        var summary = await ApplyAllAsync(content, resolutionMap, user, ct);

        // Pre-deployment semantic validator — fail fast
        var validation = _semanticValidator.Validate(BuildFlattenedConfig(content));
        if (!validation.IsValid)
            throw new SemanticValidationException(validation.Errors);

        // BundleImported summary row inside the transaction
        await _auditService.LogAsync(user, "BundleImported", "Bundle",
            bundleImportId.ToString(), session.Manifest.SourceEnvironment,
            new { session.Manifest.SourceEnvironment, session.Manifest.ContentHash, Summary = summary }, ct);

        await _context.SaveChangesAsync(ct);
        await tx.CommitAsync(ct);

        _sessionStore.Remove(sessionId);
        return new ImportResult(bundleImportId, summary.Added, summary.Overwritten, summary.Skipped, summary.Renamed, StaleInstanceIds: Array.Empty<int>(), AuditEventCorrelation: bundleImportId.ToString());
    }
    catch (Exception ex)
    {
        await tx.RollbackAsync(ct);
        // BundleImportFailed row OUTSIDE the rolled-back tx
        _correlation.BundleImportId = null;
        await _auditService.LogAsync(user, "BundleImportFailed", "Bundle",
            bundleImportId.ToString(), session.Manifest.SourceEnvironment,
            new { Reason = ex.Message }, ct);
        await _context.SaveChangesAsync(ct);
        throw;
    }
    finally
    {
        _correlation.BundleImportId = null;
    }
}
```

Note: `StaleInstanceIds: Array.Empty<int>()` because the brainstorming survey showed there's no persisted stale flag — `DeploymentService.CompareAsync` detects staleness live by comparing revision hashes, so overwriting a template surfaces stale instances automatically on the Deployments page.

**Step 5: Commit**
```bash
git commit -m "feat(transport): BundleImporter.ApplyAsync transactional with audit correlation"
```

---

## Task 18: Host registration + transport options binding

**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 19, Task 27, Task 28

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (add `builder.Services.AddTransport();` in the "Central-only components" section, alongside `AddNotificationOutbox()`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json` (add `ScadaBridge:Transport` section with defaults that match `TransportOptions`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj` (add `ProjectReference` to ZB.MOM.WW.ScadaBridge.Transport)

**Step 1: Modify Program.cs**

Find the existing `if (centralRolesActive) { ... builder.Services.AddNotificationOutbox(); ... }` block (or equivalent — search for `AddNotificationOutbox`) and add `builder.Services.AddTransport();` directly after.

**Step 2: appsettings.json**

```json
"ScadaBridge": {
  "Transport": {
    "BundleSessionTtlMinutes": 30,
    "MaxBundleSizeMb": 100,
    "MaxUnlockAttemptsPerSession": 3,
    "MaxUnlockAttemptsPerIpPerHour": 10,
    "Pbkdf2Iterations": 600000,
    "SchemaVersionMajor": 1
  }
}
```

**Step 3: Build**

Run: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`
Expected: build succeeds.

**Step 4: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.Host/
git commit -m "feat(transport): register AddTransport() on central nodes"
```

---

## Task 19: `TreeView` checkbox-selection mode + tri-state propagation

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 0, Task 1, Task 2, Task 4, Task 7, Task 8, Task 9, Task 10, Task 12, Task 13

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor`
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewMultiSelectTests.cs`

**Step 1: Extend the component API**

Add parameters:
```razor
[Parameter] public TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single;
[Parameter] public HashSet<object>? SelectedKeys { get; set; }
[Parameter] public EventCallback<HashSet<object>> SelectedKeysChanged { get; set; }
```

`TreeViewSelectionMode { Single, Checkbox }`.

In `Checkbox` mode, render a `<input type="checkbox">` left of the node content. State is:
- `Checked` — node and all descendant leaves are in `SelectedKeys`.
- `Unchecked` — none are.
- `Indeterminate` — some are. Set via `ref` + `element.indeterminate = true` in JS interop (use the existing JS bundle helper if there is one; otherwise a tiny `treeview.js` file is acceptable).

Toggle semantics:
- Clicking a folder toggles all descendants on/off.
- Clicking a leaf toggles just itself; parent indeterminate state recomputed.

**Step 2: Write failing tests**

Use bUnit (already in `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/`):
1. `Checkbox_mode_renders_checkbox_per_node`
2. `Clicking_folder_selects_all_descendants`
3. `Clicking_leaf_makes_parent_indeterminate_when_sibling_unchecked`
4. `Single_mode_still_works_unchanged` (regression)

**Step 4: Run tests**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ --filter FullyQualifiedName~TreeViewMultiSelect`
Expected: PASS.

**Step 5: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor \
        tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TreeViewMultiSelectTests.cs
git commit -m "feat(centralui): TreeView checkbox-selection mode with tri-state"
```

---

## Task 20: `TemplateFolderTree.razor` wrapper + Templates.razor refactor

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (depends on Task 19)

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TemplateFolderTree.razor`
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor` (use the new component in `Single` mode)

**Step 1: Build the wrapper**

`TemplateFolderTree.razor` wraps `TreeView<TemplateTreeNode>` with template-folder-specific configuration:
- `[Parameter] IReadOnlyList<TemplateFolder> Folders { get; set; }`
- `[Parameter] IReadOnlyList<Template> Templates { get; set; }`
- `[Parameter] TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single`
- `[Parameter] HashSet<object>? SelectedKeys { get; set; }`
- `[Parameter] EventCallback<HashSet<object>> SelectedKeysChanged { get; set; }`
- `[Parameter] string Filter { get; set; } = ""` — applies search filter in-place
- `[Parameter] RenderFragment<TemplateTreeNode>? NodeExtras { get; set; }` — optional content rendered next to node label

Internally builds a `TemplateTreeNode { Kind: Folder|Template, Id, Name, Children }` adapter tree from the input lists.

**Step 2: Refactor Templates.razor**

Replace the existing inline tree usage with `<TemplateFolderTree Folders="..." Templates="..." SelectionMode="TreeViewSelectionMode.Single" SelectedKey="@_selectedKey" ... />`. Keep all current behavior (navigation, search). The replacement should be ≤30 LOC change in Templates.razor.

**Step 3: Manual smoke (no new tests needed — existing Templates.razor tests cover this)**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj`
Then: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/`
Expected: all existing Templates page tests still pass.

**Step 4: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TemplateFolderTree.razor \
        src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor
git commit -m "refactor(centralui): extract TemplateFolderTree as shared component"
```

---

## Task 21: `TransportExport.razor` wizard

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 22 (separate page; only NavMenu touches both)

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportExport.razor`
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportExport.razor.cs` (code-behind for the wizard state machine)
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TransportExportPageTests.cs`

**Step 1: Page skeleton**

```razor
@page "/design/transport/export"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IBundleExporter BundleExporter
@inject ITemplateEngineRepository TemplateRepo
@inject IExternalSystemRepository ExternalRepo
@inject INotificationRepository NotificationRepo
@inject IInboundApiRepository InboundApiRepo
@inject IJSRuntime JS
@inject AuthenticationStateProvider Auth
@inject IOptions<TransportOptions> Options
```

Four-step wizard rendered with a step indicator at top and a single content area beneath. State machine in code-behind (`Step1Select`, `Step2Review`, `Step3Encrypt`, `Step4Download`).

Step 1 hosts the `<TemplateFolderTree SelectionMode="TreeViewSelectionMode.Checkbox" ... />` plus flat checkbox lists for shared scripts / external systems / notification lists / SMTP configs / API keys / API methods.

Step 2 calls `DependencyResolver` server-side and shows the expanded selection (user-selected + auto-included).

Step 3 collects passphrase + confirm (or explicit "Export without encryption" path with warning banner).

Step 4 calls `IBundleExporter.ExportAsync`, streams the result to the browser via `IJSRuntime.InvokeVoidAsync("scadabridgeTransport.downloadBundle", filename, bytes)`. Display SHA-256 + size.

**Step 2: Page tests with bUnit**

1. `Renders_step1_with_template_tree`
2. `Step2_shows_resolved_dependencies`
3. `Step4_triggers_ExportAsync_on_user_with_Design_role`
4. `Page_returns_403_for_user_without_Design_role`

**Step 3: Run tests**

Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ --filter FullyQualifiedName~TransportExportPage`
Expected: PASS.

**Step 4: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportExport.razor \
        src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportExport.razor.cs \
        tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TransportExportPageTests.cs
git commit -m "feat(centralui): TransportExport wizard under Design nav group"
```

---

## Task 22: `TransportImport.razor` wizard

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 21

**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor`
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs`
- Create: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TransportImportPageTests.cs`

**Step 1: Page skeleton**

```razor
@page "/design/transport/import"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IBundleImporter BundleImporter
@inject NavigationManager Nav
@inject AuthenticationStateProvider Auth
```

Five-step wizard:
1. **Upload** — `InputFile` component (size-limited to `Options.MaxBundleSizeMb`). On select, call `LoadAsync(stream, passphrase: null)` to read the manifest only.
2. **Passphrase** — render iff `manifest.Encryption != null`. Submit calls `LoadAsync` again with the passphrase. On wrong passphrase, increment a session-local counter; after 3 attempts, evict the session and force re-upload.
3. **Diff & resolve** — render `ImportPreview` rows with per-row radio buttons (Skip/Overwrite/Rename). Show field-level diff via `<details>` disclosure. Bulk "Apply to all" header.
4. **Confirm** — text input requires user to retype `manifest.SourceEnvironment` exactly.
5. **Result** — show counts + link to Deployments page (`<a href="/deployment/deployments">View on Deployments →</a>`) + link to filtered audit log (`<a href="/audit/configuration?bundleImportId={result.BundleImportId}">Audit trail →</a>`).

**Step 2: Page tests**

1. `Renders_step1_upload_input`
2. `Decryption_failure_increments_attempt_counter`
3. `Three_failed_unlocks_force_reupload`
4. `Confirm_step_requires_exact_environment_name_match`
5. `Apply_step_invokes_BundleImporter_ApplyAsync_with_resolutions`
6. `Page_returns_403_for_user_without_Admin_role`

**Step 3: Run + commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor \
        src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs \
        tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TransportImportPageTests.cs
git commit -m "feat(centralui): TransportImport wizard under Design nav group"
```

---

## Task 23: NavMenu entries + UI auth attributes verified

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (depends on Tasks 21, 22)

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor`

**Step 1: Add nav entries**

Inside the existing Design `<NavSection>`, after the Templates entry:
```razor
<li class="nav-item">
    <NavLink class="nav-link" href="/design/transport/export">Export Bundle</NavLink>
</li>
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
    <Authorized>
        <li class="nav-item">
            <NavLink class="nav-link" href="/design/transport/import">Import Bundle</NavLink>
        </li>
    </Authorized>
</AuthorizeView>
```

(Export shows for anyone with Design; Import only renders when the user also has Admin.)

**Step 2: Build + smoke-test**

Run: `dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj`
Expected: build succeeds.

**Step 3: Commit**

```bash
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor
git commit -m "feat(centralui): add Export/Import Bundle nav entries"
```

---

## Task 24: Audit log "Bundle Import" filter on existing page

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 22, Task 23

**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor` (locate by `git grep -l ConfigurationAuditLog src/ZB.MOM.WW.ScadaBridge.CentralUI/`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/` (the repository that drives the audit page — extend the query with an optional `Guid? bundleImportId` filter)

**Step 1: Extend the repository query**

Find the audit log query method (likely `IAuditLogRepository.QueryAsync(...)` or similar). Add an optional `Guid? bundleImportId = null` parameter and:
```csharp
if (bundleImportId.HasValue)
    query = query.Where(x => x.BundleImportId == bundleImportId.Value);
```

**Step 2: Wire the page filter**

In `ConfigurationAuditLog.razor`:
- Accept `[Parameter, SupplyParameterFromQuery] public Guid? BundleImportId { get; set; }`.
- Display the active filter as a removable badge ("Filtered by Bundle Import: {id}").
- Pass `BundleImportId` through to the repository call.

This is what the `TransportImport.razor` Step 5 link (`/audit/configuration?bundleImportId=…`) consumes.

**Step 3: Commit**

```bash
git commit -m "feat(centralui): Bundle Import filter on ConfigurationAuditLog page"
```

---

## Task 25: Integration: export → wipe → import round-trip

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 26, Task 27, Task 28

**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripTests.cs`

**Step 1: Write the test**

```csharp
[Fact]
public async Task Export_then_wipe_then_import_restores_state()
```

1. Seed in-memory DB with 3 templates (one composed), 1 shared script, 1 external system, 1 notification list.
2. Call `IBundleExporter.ExportAsync` selecting all of them.
3. Read the returned stream into bytes.
4. Wipe DB: delete all those entities.
5. Call `IBundleImporter.LoadAsync(new MemoryStream(bytes), passphrase)`.
6. `PreviewAsync` → all `ConflictKind.New`.
7. Apply with all `Add` resolutions.
8. Re-query DB. Assert every entity is back, field-equivalent to the seed.
9. Assert one `BundleExported` + one `BundleImported` audit row exists, both linked by content hash.
10. Assert every per-entity audit row written during apply has `BundleImportId == result.BundleImportId`.

**Step 2: Run + commit**

```bash
git add tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripTests.cs
git commit -m "test(transport): integration round-trip export → wipe → import"
```

---

## Task 26: Integration: conflict resolutions + rollback on validation failure

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 25, Task 27, Task 28

**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ConflictResolutionTests.cs`
- Create: `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ValidationFailureTests.cs`

**Step 1: Write the tests**

`ConflictResolutionTests`:
1. `Overwrite_replaces_existing_template_fields`
2. `Skip_leaves_existing_template_unchanged`
3. `Rename_creates_new_template_alongside_existing`

`ValidationFailureTests`:
1. `Semantic_validation_failure_rolls_back_all_writes` — craft a bundle with a template whose script references a missing call target. Apply must throw, transaction must roll back, no rows from the bundle persist, AND a `BundleImportFailed` row exists outside the rolled-back transaction.

**Step 3: Commit**

```bash
git add tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/
git commit -m "test(transport): integration conflict resolution + rollback"
```

---

## Task 27: `Component-Transport.md` design doc

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 25, Task 26, Task 28

**Files:**
- Create: `docs/requirements/Component-Transport.md`

**Step 1: Write the doc**

Follow the standard structure used by `docs/requirements/Component-NotificationOutbox.md`:
- **Purpose** — one-paragraph summary.
- **Location** — paths.
- **Responsibilities** — bullet list.
- **Bundle format** — copy §4 from `2026-05-24-transport-design.md` plus JSON schema appendix.
- **Architecture** — copy §5.
- **Export flow / Import flow** — copy §6 + §7.
- **Error handling** — copy §8.
- **Security** — copy §9.
- **Configuration audit trail** — copy §10.
- **Authorization** — copy §12.
- **Dependencies** — Commons, ConfigurationDatabase, TemplateEngine.
- **Interactions** — Central UI (pages), Deployment Manager (stale-detection via existing CompareAsync), Security & Auth (RequireDesign/RequireAdmin).

**Step 2: Commit**

```bash
git add docs/requirements/Component-Transport.md
git commit -m "docs: Component-Transport.md (component #24)"
```

---

## Task 28: README + cross-reference updates

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 25, Task 26, Task 27

**Files:**
- Modify: `README.md` (component table — add row #24)
- Modify: `docs/requirements/Component-TemplateEngine.md` (Interactions section — mention Transport as consumer)
- Modify: `docs/requirements/Component-DeploymentManager.md` (Interactions section — note that Transport-driven template overwrites surface naturally on existing CompareAsync)
- Modify: `docs/requirements/Component-SecurityAuth.md` (record the new `RequireDesign` export + `RequireAdmin` import mapping)
- Modify: `docs/requirements/Component-ConfigurationDatabase.md` (record the new `BundleImportId` column + index)
- Modify: `docs/requirements/Component-CentralUI.md` (record the two new pages under Design)
- Modify: `CLAUDE.md` (component list — add #24 Transport)

**Step 1: README table row**

After the row for component #23:
```markdown
| 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. |
```

**Step 2: CLAUDE.md**

Add to the Current Component List:
```markdown
24. Transport — File-based, encrypted bundle export/import via Central UI; templates, system artifacts, central-only configuration; per-conflict resolution; correlated audit via `BundleImportId`.
```

**Step 3: Cross-reference updates** — single sentence per doc as described in the design doc §16.

**Step 4: Commit**

```bash
git add README.md CLAUDE.md docs/requirements/
git commit -m "docs: README + component cross-references for Transport (#24)"
```

---

## Task 29: Manual cluster verification checklist

**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none (final verification gate)

**Files:**
- Create: `docs/plans/2026-05-24-transport-manual-verification.md`

**Step 1: Write the checklist**

The verification doc lists steps to be done manually against the docker cluster (no automation in this task — the checklist is the deliverable):

```markdown
# Transport Manual Verification

## Prerequisites
- `bash docker/deploy.sh` succeeded (image rebuilt with Transport).
- `cd infra && docker compose up -d` (LDAP, SQL ready).

## Steps
1. Log in to http://localhost:9000 as `multi-role` / `password`.
2. Design → Export Bundle.
3. Select 1 template + 1 shared script. Verify dependency expansion in Step 2.
4. Set passphrase "test123". Export. Verify .scadabundle file downloads.
5. Log out, log in as `admin` / `password`.
6. Design → Import Bundle. Upload the bundle.
7. Enter passphrase "test123". Verify diff page shows the artifacts.
8. Apply with Add for all. Confirm by retyping source env.
9. Verify Step 5 result page shows counts + links.
10. Open Audit → Configuration Audit Log → filter by the BundleImported row's id.
    Verify all per-entity rows from the import are listed.
11. Open Deployments page → verify any instance that referenced the overwritten
    template appears stale (revision-hash mismatch).
12. Verify wrong passphrase fails cleanly (3 attempts → re-upload required).
```

**Step 2: Commit**

```bash
git add docs/plans/2026-05-24-transport-manual-verification.md
git commit -m "docs(transport): manual cluster verification checklist"
```

---

## Acceptance Gate (run before declaring done)

After Task 29:

```bash
dotnet build ZB.MOM.WW.ScadaBridge.slnx
dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/
dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/
dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/
dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/
```

All must pass. Then proceed to Task 29 manual checklist against the docker cluster.

---

## Out-of-Scope Reminders (do NOT do)

- Do NOT add a feature flag.
- Do NOT touch site-side projects (`ZB.MOM.WW.ScadaBridge.SiteRuntime` etc.).
- Do NOT add a stale-mark column on `Instance` — the existing `DeploymentService.CompareAsync` already detects this.
- CLI commands shipped as `scadabridge bundle export/preview/import` (see `BundleCommands.cs` and design doc §13 — originally deferred but subsequently built).
- Do NOT add bundle signing — content hash in manifest is sufficient for v1.
- Do NOT retain bundles server-side after export download or import apply.
