# 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` 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`: ```csharp 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 Contents); ``` `EncryptionMetadata.cs`: ```csharp 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`: ```csharp 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`: ```csharp namespace ScadaLink.Commons.Types.Transport; public sealed record ManifestContentEntry( string Type, string Name, int Version, IReadOnlyList DependsOn); ``` **Step 2: Build** Run: `dotnet build src/ScadaLink.Commons/ScadaLink.Commons.csproj` Expected: build succeeds. **Step 3: Commit** ```bash 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`: ```csharp namespace ScadaLink.Commons.Types.Transport; public sealed record ExportSelection( IReadOnlyList TemplateIds, IReadOnlyList SharedScriptIds, IReadOnlyList ExternalSystemIds, IReadOnlyList DatabaseConnectionIds, IReadOnlyList NotificationListIds, IReadOnlyList SmtpConfigurationIds, IReadOnlyList ApiKeyIds, IReadOnlyList ApiMethodIds, bool IncludeDependencies); ``` `ImportPreview.cs`: ```csharp 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 Items); ``` `ImportResolution.cs`: ```csharp 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`: ```csharp namespace ScadaLink.Commons.Types.Transport; public sealed record ImportResult( Guid BundleImportId, int Added, int Overwritten, int Skipped, int Renamed, IReadOnlyList StaleInstanceIds, string AuditEventCorrelation); ``` `BundleSession.cs`: ```csharp 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(); public DateTimeOffset ExpiresAt { get; init; } public int FailedUnlockAttempts { get; set; } public bool Locked => FailedUnlockAttempts >= 3; } ``` `IBundleExporter.cs`: ```csharp using ScadaLink.Commons.Types.Transport; namespace ScadaLink.Commons.Interfaces.Transport; public interface IBundleExporter { Task ExportAsync( ExportSelection selection, string user, string sourceEnvironment, string? passphrase, CancellationToken cancellationToken = default); } ``` `IBundleImporter.cs`: ```csharp using ScadaLink.Commons.Types.Transport; namespace ScadaLink.Commons.Interfaces.Transport; public interface IBundleImporter { Task LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default); Task PreviewAsync(Guid sessionId, CancellationToken ct = default); Task ApplyAsync( Guid sessionId, IReadOnlyList resolutions, string user, CancellationToken ct = default); } ``` `IBundleSessionStore.cs`: ```csharp 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`: ```csharp namespace ScadaLink.Commons.Interfaces.Transport; /// /// 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. /// 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** ```bash 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`: ```csharp public Guid? BundleImportId { get; set; } ``` **Step 2: Add Fluent config + index** In `ScadaLinkDbContext.OnModelCreating`, find the existing `modelBuilder.Entity(...)` 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/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj` Expected: build succeeds. **Step 4: Commit** ```bash 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/_AddBundleImportIdToAuditLog.cs` (generated) - Create: `src/ScadaLink.ConfigurationDatabase/Migrations/_AddBundleImportIdToAuditLog.Designer.cs` (generated) **Step 1: Generate** Run: ```bash 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(...)` 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** ```bash 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`: ```csharp using ScadaLink.Commons.Interfaces.Transport; namespace ScadaLink.ConfigurationDatabase.Services; /// /// 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. /// public sealed class AuditCorrelationContext : IAuditCorrelationContext { public Guid? BundleImportId { get; set; } } ``` **Step 2: Register** In `src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs` (the `AddConfigurationDatabase()` method), add: ```csharp services.AddScoped(); ``` If a `ServiceCollectionExtensions.cs` doesn't exist in `ScadaLink.ConfigurationDatabase`, look at where `IAuditService` is currently registered (search the repo for `AddScoped` 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`: ```xml net10.0 enable enable true ``` `ServiceCollectionExtensions.cs`: ```csharp 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().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** ```bash 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** ```csharp 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** ```bash 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** ```csharp 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 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 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** ```bash 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:** ```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/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: ```csharp public sealed record ExternalSystemDto( string Name, string BaseUrl, string AuthType, IReadOnlyList Methods, SecretsBlock? Secrets); public sealed record SecretsBlock(IReadOnlyDictionary 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 TemplateFolders, IReadOnlyList Templates, IReadOnlyList SharedScripts, IReadOnlyList ExternalSystems, IReadOnlyList DatabaseConnections, IReadOnlyList NotificationLists, IReadOnlyList SmtpConfigs, IReadOnlyList ApiKeys, IReadOnlyList 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/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** ```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/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: ```csharp public sealed record ResolvedExport( IReadOnlyList TemplateFolders, IReadOnlyList