Files
scadalink-design/docs/plans/2026-05-24-transport.md
Joseph Doherty 1bc98e10a1 docs(plans): add Transport (Component #24) implementation plan
30-task plan covering ScadaLink.Transport project, AES-256-GCM bundle
encryption, IAuditCorrelationContext for BundleImportId threading,
TreeView checkbox-selection mode + TemplateFolderTree wrapper, two
Central UI wizard pages, EF migration, integration tests, README +
cross-reference updates. Single shipping slice, no feature flag.
2026-05-24 03:43:18 -04:00

60 KiB
Raw Blame History

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 ScadaLink.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/ScadaLink.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/ScadaLink.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/ScadaLink.Host/Program.cs under "Central-only components".
  • Test integration pattern: ScadaLinkWebApplicationFactory : 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 ScadaLink.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/ScadaLink.Commons/Types/Transport/BundleManifest.cs
  • Create: src/ScadaLink.Commons/Types/Transport/BundleSummary.cs
  • Create: src/ScadaLink.Commons/Types/Transport/EncryptionMetadata.cs
  • Create: src/ScadaLink.Commons/Types/Transport/ManifestContentEntry.cs

Step 1: Write the DTOs

BundleManifest.cs:

namespace ScadaLink.Commons.Types.Transport;

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

EncryptionMetadata.cs:

namespace ScadaLink.Commons.Types.Transport;

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

BundleSummary.cs:

namespace ScadaLink.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:

namespace ScadaLink.Commons.Types.Transport;

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

Step 2: Build

Run: dotnet build src/ScadaLink.Commons/ScadaLink.Commons.csproj Expected: build succeeds.

Step 3: Commit

git add src/ScadaLink.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/ScadaLink.Commons/Types/Transport/ExportSelection.cs
  • Create: src/ScadaLink.Commons/Types/Transport/ImportPreview.cs
  • Create: src/ScadaLink.Commons/Types/Transport/ImportResolution.cs
  • Create: src/ScadaLink.Commons/Types/Transport/ImportResult.cs
  • Create: src/ScadaLink.Commons/Types/Transport/BundleSession.cs
  • Create: src/ScadaLink.Commons/Interfaces/Transport/IBundleExporter.cs
  • Create: src/ScadaLink.Commons/Interfaces/Transport/IBundleImporter.cs
  • Create: src/ScadaLink.Commons/Interfaces/Transport/IBundleSessionStore.cs
  • Create: src/ScadaLink.Commons/Interfaces/Transport/IAuditCorrelationContext.cs

Step 1: Write the types and interfaces

ExportSelection.cs:

namespace ScadaLink.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:

namespace ScadaLink.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:

namespace ScadaLink.Commons.Types.Transport;

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

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

ImportResult.cs:

namespace ScadaLink.Commons.Types.Transport;

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

BundleSession.cs:

namespace ScadaLink.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:

using ScadaLink.Commons.Types.Transport;

namespace ScadaLink.Commons.Interfaces.Transport;

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

IBundleImporter.cs:

using ScadaLink.Commons.Types.Transport;

namespace ScadaLink.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:

using ScadaLink.Commons.Types.Transport;

namespace ScadaLink.Commons.Interfaces.Transport;

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

IAuditCorrelationContext.cs:

namespace ScadaLink.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/ScadaLink.Commons/ScadaLink.Commons.csproj Expected: build succeeds.

Step 3: Commit

git add src/ScadaLink.Commons/Types/Transport/ src/ScadaLink.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/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs (add nullable BundleImportId)
  • Modify: src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.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:

public Guid? BundleImportId { get; set; }

Step 2: Add Fluent config + index

In ScadaLinkDbContext.OnModelCreating, find the existing modelBuilder.Entity<AuditLogEntry>(...) block and add:

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/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj Expected: build succeeds.

Step 4: Commit

git add src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs \
        src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.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/ScadaLink.ConfigurationDatabase/Migrations/<timestamp>_AddBundleImportIdToAuditLog.cs (generated)
  • Create: src/ScadaLink.ConfigurationDatabase/Migrations/<timestamp>_AddBundleImportIdToAuditLog.Designer.cs (generated)

Step 1: Generate

Run:

dotnet ef migrations add AddBundleImportIdToAuditLog \
    -p src/ScadaLink.ConfigurationDatabase \
    -s src/ScadaLink.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/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj Expected: build succeeds.

Step 4: Commit

git add src/ScadaLink.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/ScadaLink.ConfigurationDatabase/Services/AuditCorrelationContext.cs
  • Modify: src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs (add scoped registration; if no file, create alongside existing convention used by audit service)

Step 1: Write the implementation

AuditCorrelationContext.cs:

using ScadaLink.Commons.Interfaces.Transport;

namespace ScadaLink.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/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs (the AddConfigurationDatabase() method), add:

services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();

If a ServiceCollectionExtensions.cs doesn't exist in ScadaLink.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/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj Expected: build succeeds.

Step 4: Commit

git add src/ScadaLink.ConfigurationDatabase/Services/AuditCorrelationContext.cs \
        src/ScadaLink.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/ScadaLink.ConfigurationDatabase/Services/AuditService.cs
  • Modify: tests/ScadaLink.Commons.Tests/AuditServiceTests.cs (if exists) OR create tests/ScadaLink.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/ScadaLink.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/ScadaLink.ConfigurationDatabase.Tests/ Expected: PASS.

Step 5: Commit

git add src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs \
        tests/ScadaLink.ConfigurationDatabase.Tests/
git commit -m "feat(transport): AuditService stamps BundleImportId from correlation context"

Task 6: ScadaLink.Transport project skeleton

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

Files:

  • Create: src/ScadaLink.Transport/ScadaLink.Transport.csproj
  • Create: src/ScadaLink.Transport/ServiceCollectionExtensions.cs
  • Modify: ScadaLink.slnx (add <Project Path="src/ScadaLink.Transport/ScadaLink.Transport.csproj" /> in alphabetical position)
  • Create: tests/ScadaLink.Transport.Tests/ScadaLink.Transport.Tests.csproj
  • Create: tests/ScadaLink.Transport.IntegrationTests/ScadaLink.Transport.IntegrationTests.csproj
  • Modify: ScadaLink.slnx (add the two test projects)

Step 1: Write the csproj

ScadaLink.Transport.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
    <ProjectReference Include="../ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
    <ProjectReference Include="../ScadaLink.TemplateEngine/ScadaLink.TemplateEngine.csproj" />
  </ItemGroup>
  <ItemGroup>
    <InternalsVisibleTo Include="ScadaLink.Transport.Tests" />
    <InternalsVisibleTo Include="ScadaLink.Transport.IntegrationTests" />
  </ItemGroup>
</Project>

ServiceCollectionExtensions.cs:

using Microsoft.Extensions.DependencyInjection;

namespace ScadaLink.Transport;

public static class ServiceCollectionExtensions
{
    public const string OptionsSection = "ScadaLink: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/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj for shape — xUnit + NSubstitute + FluentAssertions + ProjectReference back to ScadaLink.Transport).

Step 2: Add to slnx

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

Step 3: Build

Run: dotnet build ScadaLink.slnx Expected: build succeeds (Transport empty + tests empty).

Step 4: Commit

git add src/ScadaLink.Transport/ tests/ScadaLink.Transport.Tests/ tests/ScadaLink.Transport.IntegrationTests/ ScadaLink.slnx
git commit -m "feat(transport): scaffold ScadaLink.Transport project + test projects"

Task 7: TransportOptions

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

Files:

  • Create: src/ScadaLink.Transport/TransportOptions.cs

Step 1: Write the options class

namespace ScadaLink.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

git add src/ScadaLink.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/ScadaLink.Transport/Encryption/BundleSecretEncryptor.cs
  • Create: tests/ScadaLink.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/ScadaLink.Transport.Tests/ --filter FullyQualifiedName~BundleSecretEncryptorTests Expected: FAIL (class not yet defined).

Step 3: Implement

using System.Security.Cryptography;
using ScadaLink.Commons.Types.Transport;

namespace ScadaLink.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/ScadaLink.Transport.Tests/ --filter FullyQualifiedName~BundleSecretEncryptorTests Expected: PASS.

Step 5: Commit

git add src/ScadaLink.Transport/Encryption/ tests/ScadaLink.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/ScadaLink.Transport/Serialization/ManifestBuilder.cs
  • Create: src/ScadaLink.Transport/Serialization/ManifestValidator.cs
  • Create: tests/ScadaLink.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, scadaLinkVersion, 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:

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/ScadaLink.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/ScadaLink.Transport/Serialization/EntitySerializer.cs
  • Create: tests/ScadaLink.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:

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:

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

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/ScadaLink.Transport/Serialization/BundleSerializer.cs
  • Create: tests/ScadaLink.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

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/ScadaLink.Transport/Export/DependencyResolver.cs
  • Create: tests/ScadaLink.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:

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

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/ScadaLink.Transport/Import/BundleSessionStore.cs
  • Create: tests/ScadaLink.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

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 812)

Files:

  • Create: src/ScadaLink.Transport/Export/BundleExporter.cs
  • Create: tests/ScadaLink.Transport.IntegrationTests/BundleExporterTests.cs (integration — needs DB)
  • Modify: src/ScadaLink.Transport/ServiceCollectionExtensions.cs (register IBundleExporter -> BundleExporter)

Step 1: Write failing integration test

Test against in-memory EF:

[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:

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

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/ScadaLink.Transport/Import/BundleImporter.cs
  • Create: tests/ScadaLink.Transport.Tests/BundleImporterLoadTests.cs
  • Modify: src/ScadaLink.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:

services.AddScoped<IBundleImporter, BundleImporter>();
services.AddSingleton<IBundleSessionStore, BundleSessionStore>();

Step 5: Commit

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/ScadaLink.Transport/Import/BundleImporter.cs
  • Create: src/ScadaLink.Transport/Import/ArtifactDiff.cs
  • Create: tests/ScadaLink.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 ImportPreviewItems. Blocker items appear when a referenced shared script / external system is neither in the bundle nor pre-existing in the target.

Step 5: Commit

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/ScadaLink.Transport/Import/BundleImporter.cs
  • Create: tests/ScadaLink.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:

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

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/ScadaLink.Host/Program.cs (add builder.Services.AddTransport(); in the "Central-only components" section, alongside AddNotificationOutbox())
  • Modify: src/ScadaLink.Host/appsettings.json (add ScadaLink:Transport section with defaults that match TransportOptions)
  • Modify: src/ScadaLink.Host/ScadaLink.Host.csproj (add ProjectReference to ScadaLink.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

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

Step 3: Build

Run: dotnet build ScadaLink.slnx Expected: build succeeds.

Step 4: Commit

git add src/ScadaLink.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/ScadaLink.CentralUI/Components/Shared/TreeView.razor
  • Create: tests/ScadaLink.CentralUI.Tests/TreeViewMultiSelectTests.cs

Step 1: Extend the component API

Add parameters:

[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/ScadaLink.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/ScadaLink.CentralUI.Tests/ --filter FullyQualifiedName~TreeViewMultiSelect Expected: PASS.

Step 5: Commit

git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor \
        tests/ScadaLink.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/ScadaLink.CentralUI/Components/Shared/TemplateFolderTree.razor
  • Modify: src/ScadaLink.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/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj Then: dotnet test tests/ScadaLink.CentralUI.Tests/ Expected: all existing Templates page tests still pass.

Step 4: Commit

git add src/ScadaLink.CentralUI/Components/Shared/TemplateFolderTree.razor \
        src/ScadaLink.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/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor
  • Create: src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor.cs (code-behind for the wizard state machine)
  • Create: tests/ScadaLink.CentralUI.Tests/TransportExportPageTests.cs

Step 1: Page skeleton

@page "/design/transport/export"
@using ScadaLink.Security
@using ScadaLink.Commons.Types.Transport
@using ScadaLink.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("scadalinkTransport.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/ScadaLink.CentralUI.Tests/ --filter FullyQualifiedName~TransportExportPage Expected: PASS.

Step 4: Commit

git add src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor \
        src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor.cs \
        tests/ScadaLink.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/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor
  • Create: src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs
  • Create: tests/ScadaLink.CentralUI.Tests/TransportImportPageTests.cs

Step 1: Page skeleton

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

Five-step wizard:

  1. UploadInputFile 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

git add src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor \
        src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs \
        tests/ScadaLink.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/ScadaLink.CentralUI/Components/Layout/NavMenu.razor

Step 1: Add nav entries

Inside the existing Design <NavSection>, after the Templates entry:

<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/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj Expected: build succeeds.

Step 3: Commit

git add src/ScadaLink.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/ScadaLink.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor (locate by git grep -l ConfigurationAuditLog src/ScadaLink.CentralUI/)
  • Modify: src/ScadaLink.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:

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

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/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs

Step 1: Write the test

[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

git add tests/ScadaLink.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/ScadaLink.Transport.IntegrationTests/ConflictResolutionTests.cs
  • Create: tests/ScadaLink.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

git add tests/ScadaLink.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

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:

| 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:

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

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):

# 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

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:

dotnet build ScadaLink.slnx
dotnet test tests/ScadaLink.Transport.Tests/
dotnet test tests/ScadaLink.Transport.IntegrationTests/
dotnet test tests/ScadaLink.CentralUI.Tests/
dotnet test tests/ScadaLink.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 (ScadaLink.SiteRuntime etc.).
  • Do NOT add a stale-mark column on Instance — the existing DeploymentService.CompareAsync already detects this.
  • Do NOT build CLI commands — those are deferred per design doc §13.
  • 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.