Files
ScadaBridge/docs/components/Transport.md
T
Joseph Doherty 90ce099928 feat(transport): counter for import-session cap rejections (register row 66)
Row 53 shipped the scadabridge.central.transport.import_sessions.open gauge
and a Warning log on a cap hit, but a log line is not something an operator
can alert on. Adds the companion counter

    scadabridge.central.transport.import_sessions.rejected

on the existing ZB.MOM.WW.ScadaBridge meter, so no ObservedMeters allowlist
change is needed (an unlisted meter exports nothing, silently).

The emit point is deliberately narrow. BundleSessionStore.Open tests the cap
TWICE — once on entry and again after EvictExpired() — and only the second
test is a rejection: a store full of merely expired sessions reclaims a slot
and admits the caller. Counting the first would make the series fire
routinely on any node whose sweep had not yet run, turning a page-worthy
signal into background noise. Re-Opening an already-open session id skips
the cap block entirely and is likewise not counted.

The two instruments are diagnostic as a pair: rejections rising while the
gauge oscillates below the cap is genuine concurrency (raise the cap);
rejections rising while the gauge sits pinned at the cap is a session leak
(register row 43's signature), where raising the cap only defers the problem.

Tests assert a delta over a scoped action rather than an absolute value, so
they do not inherit the register row 32 static-state flake that makes
QueueDepthGaugeTests fail only under the full concurrent suite. Negative
controls cover under-cap, expired-reclaim, and duplicate-session-id.

Tests: 11 passed in BundleSessionObservabilityTests (6 existing + 5 new).
2026-08-15 12:42:56 -04:00

30 KiB
Raw Blame History

Transport

The Transport component provides file-based, encrypted bundle export and import of central configuration artifacts between ScadaBridge environments via the Central UI. It is purely central — no site nodes are touched, no runtime state moves, and no site-scoped artifacts travel in a bundle.

Overview

Transport (#24) is a central-only component that lives in src/ZB.MOM.WW.ScadaBridge.Transport/, split into three functional areas:

  • Export/ — BundleExporter, DependencyResolver, ResolvedExport. The export pipeline resolves artifact dependencies, serializes entities to wire-shaped DTOs, optionally encrypts the content, and produces a ZIP-formatted .scadabundle stream.
  • Import/ — BundleImporter, BundleSessionStore, BundleSessionEvictionService, ArtifactDiff, BundleUnlockRateLimiter. The import pipeline validates the bundle envelope, decrypts it, diffs against the target environment, and applies operator-chosen conflict resolutions in a single EF transaction.
  • Serialization/ and Encryption/ — BundleSerializer, EntitySerializer, ManifestBuilder, ManifestValidator, BundleSecretEncryptor, BundleManifestAad. Stateless helpers that handle ZIP packing/unpacking, DTO projection, SHA-256 hashing, and AES-256-GCM authenticated encryption.

The single DI entry point is ServiceCollectionExtensions.AddTransport, registered by Host for central roles only. Stateless helpers are singletons; the exporter and importer are scoped because they reach into per-request EF Core scopes and audited repositories.

Key Concepts

Bundle format

A .scadabundle file is a ZIP archive with exactly two entries:

bundle.scadabundle
├── manifest.json     # always plaintext; never encrypted
└── content.json      # plaintext artifact data (no passphrase)
 OR content.enc       # AES-256-GCM ciphertext (passphrase supplied)

manifest.json is always plaintext so the import wizard can display source provenance and artifact counts before the operator supplies a passphrase. BundleManifest carries: BundleFormatVersion, SchemaVersion, CreatedAtUtc, SourceEnvironment, ExportedBy, ScadaBridgeVersion, ContentHash (sha256:<hex> of the raw content bytes), optional Encryption metadata, a Summary (artifact counts by type), and a Contents list (one ManifestContentEntry per artifact with its dependsOn edges).

BundleFormatVersion is an integer gate: the importer requires BundleFormatVersion to equal ManifestBuilder.CurrentBundleFormatVersion (currently 1) and rejects any other value — higher or lower — with ManifestValidationResult.UnsupportedFormatVersion. TransportOptions.SchemaVersionMajor is not read during import. Unknown entity types in Contents produce a preview-row classification of "unsupported" rather than aborting the whole import.

Encrypted vs plaintext bundles

When a passphrase is supplied, BundleSecretEncryptor derives a 256-bit key via PBKDF2-SHA256 (default 600,000 iterations, configurable) from a fresh 16-byte random salt, then encrypts the UTF-8 JSON content using AES-256-GCM with a fresh 12-byte nonce. Output format is ciphertext ‖ GCM-tag (16-byte tag appended). The salt and nonce are stored in manifest.json; the passphrase is never persisted. An unencrypted export is permitted but produces an UnencryptedBundleExport audit event rather than BundleExported.

AAD binding (T-005)

BundleManifestAad.Compute produces AES-GCM Associated Authenticated Data by SHA-256-hashing a canonicalized form of the manifest with ContentHash zeroed and Encryption nulled. This binds SourceEnvironment, ExportedBy, Summary, and Contents to the GCM authentication tag. Tampering with any of those fields on a stolen bundle yields an AuthenticationTagMismatchException on decryption, making the Step-4 "type the source environment to confirm" gate tamper-evident.

BundleSession and session lifecycle

After LoadAsync validates and decrypts a bundle, the plaintext content is stored in a BundleSession held by the singleton BundleSessionStore (a ConcurrentDictionary<Guid, BundleSession>). Sessions have a 30-minute TTL (BundleSessionTtlMinutes). BundleSessionEvictionService sweeps the store every minute, but that only reclaims sessions whose TTL has already elapsed — it is a backstop, not an abandonment path. ApplyAsync explicitly zeros and removes the session on both success and failure (T-007).

IBundleImporter.Abandon(sessionId) is the explicit release for a session that will never be applied, applying the same zero-then-remove ordering as ApplyAsync. It is idempotent (double release, unknown id, and already-expired id are all silent no-ops) and, because session ids are random GUIDs handed only to their opener, can never release another caller's session. Every abandonment path calls it:

  • Central UI wizard — new upload, Back-to-Upload / ResetSessionState, the 3-strike lockout reset, and Dispose (navigation away or circuit teardown).
  • CLI transport preview — a preview never applies, so the handler releases in a finally.
  • CLI transport import — covers the aborts that happen before ApplyAsync (blocker rows, unresolved site/connection mappings).

Without this, an abandoned wizard or a plain CLI preview held one of the eight MaxConcurrentImportSessions slots — and the decrypted bundle content — for the full TTL; eight of them locked out every import on the active node (deferred register row 43, observed live).

Session-cap observability (register row 53). That leak was only ever inferable from rotating test failures, because nothing counted open sessions and nothing logged a rejection. Two signals now close the gap:

  • Gauge scadabridge.central.transport.import_sessions.open — open (unexpired) sessions on this node, on the ZB.MOM.WW.ScadaBridge meter, which is already in SiteServiceRegistration.ObservedMeters (the ZbTelemetryOptions.Meters allowlist; an unlisted meter exports nothing, silently). The provider is registered by BundleSessionEvictionService.StartAsync and identity-checked-cleared on StopAsync, mirroring the StoreAndForward queue-depth gauge. IBundleSessionStore.OpenSessionCount deliberately excludes expired-but-unswept entries — counting the raw dictionary would report the node as pinned at the cap for up to a sweep interval after a burst had actually drained, which is the exact false positive this instrument exists to rule out. Transport is registered only in the host's Central branch, so the gauge reports 0 on a site node.

  • Counter scadabridge.central.transport.import_sessions.rejected (register row 66) — bundle-import sessions turned away because the cap was exhausted, on the same meter (so likewise no allowlist change). Row 53 shipped the gauge and a Warning log, but a log line cannot be alerted on; this is the series to alert on. It is incremented only at the post-eviction recheck in BundleSessionStore.Open: the cap is tested twice, and a store full of merely expired sessions reclaims and admits the caller, so counting the first test would fire the signal routinely on any node whose sweep has not yet run. Re-Opening an already-open session id skips the cap block entirely and is likewise never counted.

    Read the two instruments together — the pair is what discriminates the failure modes. Rejections climbing while the gauge oscillates below the cap is genuine concurrency: raise MaxConcurrentImportSessions. Rejections climbing while the gauge sits pinned at the cap is a session leak (the register row 43 signature); raising the cap only defers it, so find the leak instead.

  • Warning log on rejection — BundleSessionStore.Open logs the open count, the cap, and the TTL when it rejects. It fires only on a real cap hit: a store full of expired sessions is reclaimed first and logs nothing. A count that stays pinned at the cap across repeated warnings is a leak; an occasional warning is genuine concurrency, and the fix there is to raise ScadaBridge:Transport:MaxConcurrentImportSessions (each slot pins up to MaxBundleSizeMb of decrypted plaintext, so the cap is a memory budget).

Conflict resolution and BundleImportId correlation

PreviewAsync compares each bundle artifact against the target environment using ArtifactDiff, classifying items as Identical, Modified, New, or Blocker. The operator assigns a ResolutionAction (Add, Overwrite, Skip, Rename) per item. ApplyAsync honours those resolutions in a single EF transaction and threads a new BundleImportId GUID through every per-entity audit row via the scoped IAuditCorrelationContext. This makes every configuration row written by a bundle import queryable as a group from the audit log.

Architecture

Export pipeline

BundleExporter.ExportAsync orchestrates these steps in sequence:

  1. DependencyResolver.ResolveAsync — expands the operator's ExportSelection to the full transitive closure, then topologically sorts templates (base-before-derived via Kahn's algorithm).
  2. EntitySerializer.ToBundleContent — projects EF entity POCOs to wire-shaped DTOs (BundleContentDto), carving secret fields (connection strings, credentials, OAuth tokens) into per-entity SecretsBlock records.
  3. ManifestBuilder.Build — stamps BundleFormatVersion, SchemaVersion, SHA-256 ContentHash, Summary, and Contents into a BundleManifest.
  4. BundleSerializer.Pack — serializes the manifest and content into a ZIP stream. When a passphrase is present, BundleSecretEncryptor.Encrypt runs with a fresh salt and nonce; Pack re-stamps ContentHash and Encryption in the manifest against the ciphertext it actually writes.
  5. Audit — IAuditService.LogAsync writes one BundleExported (or UnencryptedBundleExport) row with the SHA-256 of the full ZIP stream as EntityId.
public sealed class BundleExporter : IBundleExporter
{
    public async Task<Stream> ExportAsync(
        ExportSelection selection,
        string user,
        string sourceEnvironment,
        string? passphrase,
        CancellationToken cancellationToken = default)
    {
        var resolved   = await _resolver.ResolveAsync(selection, cancellationToken);
        var aggregate  = new EntityAggregate(/* resolved collections */);
        var contentDto = _entitySerializer.ToBundleContent(aggregate);
        var summary    = new BundleSummary(/* counts from resolved */);
        EncryptionMetadata? encryptionSeed = passphrase is null ? null
            : new EncryptionMetadata("AES-256-GCM", "PBKDF2-SHA256",
                _options.Value.Pbkdf2Iterations, string.Empty, string.Empty);
        var manifest = _manifestBuilder.Build(sourceEnvironment, user, assemblyVersion,
            encryptionSeed, summary, resolved.ContentManifest,
            _bundleSerializer.SerializeContentBytes(contentDto));
        var zipStream = _bundleSerializer.Pack(contentDto, manifest, passphrase, _encryptor);
        var bundleHash = ComputeStreamSha256(zipStream);
        await _auditService.LogAsync(user,
            passphrase is null ? "UnencryptedBundleExport" : "BundleExported",
            "Bundle", bundleHash, sourceEnvironment, /* afterState */, cancellationToken);
        zipStream.Position = 0;
        return zipStream;
    }
}

Dependency expansion

DependencyResolver walks five dependency edge types when ExportSelection.IncludeDependencies is true:

Edge Mechanism
Template composes Template TemplateComposition.ComposedTemplateId (BFS over composition graph)
Template references SharedScript Name-scan of TemplateScript.Code, TemplateAttribute.Value, and TemplateAttribute.DataSourceReference
Template references ExternalSystem Name-scan of TemplateScript.Code, TemplateAttribute.DataSourceReference, and TemplateAttribute.Value
ApiMethod references SharedScript Name-scan of ApiMethod.Script
Template folder ancestor chain Always included regardless of IncludeDependencies

ExternalSystemMethod records always travel with their parent ExternalSystemDefinition. Templates are emitted in topological order (base-before-derived) so the importer can Apply* them in sequence without forward-reference gaps.

Inbound API keys are explicitly excluded — per re-architecture C4, keys are environment-specific and must be re-issued on the target cluster. ApiMethod definitions travel without key bindings.

Import pipeline

BundleImporter is a three-phase service:

Phase 1 — LoadAsync: copies the upload stream to a seekable MemoryStream, enforces the bundle size cap (MaxBundleSizeMb), validates the ZIP envelope (entry count, per-entry decompressed size, compression ratio — all before decompression), reads and validates manifest.json (format version, SHA-256 content hash), and decrypts content.enc when Encryption is present. The decrypted bytes are stored in a new BundleSession.

Passphrase lockout operates at two levels: per-bundle (3-strike counter in BundleSessionStore, keyed by ContentHash so a second browser tab sharing the same bundle bytes cannot reset the counter) and per-IP-per-hour (BundleUnlockRateLimiter, MaxUnlockAttemptsPerIpPerHour, default 10 attempts). A successful decrypt clears the per-bundle counter.

The two levels interact in a specific order, and it is load-bearing (register row 44 — the limiter had been registered, injected and unit-tested but never called, so only the per-bundle lockout was real):

  1. Per-bundle lockout first. An already-locked bundle throws BundleLockedException (Commons — register row 51 moved it out of Transport.Import for the same reason the throttle exception lives there: ManagementService does not reference Transport, so a lockout used to reach the CLI as MapFault's generic "An internal error occurred" plus a correlation id) without registering a rate-limiter attempt, so hammering a locked bundle cannot spend the client's hourly budget.
  2. Per-IP throttle next, before the decrypt. Over budget throws BundleUnlockThrottledException (Commons, so the management surface can catch it and return a curated message). Because it precedes the decrypt, a denial burns no per-bundle strike — otherwise the throttle would be a way to lock out a bundle someone is legitimately importing. Every unlock attempt is registered, successful ones included: the documented budget counts attempts, not failures.
  3. Only then the AES-GCM decrypt, whose failure increments the per-bundle counter as before.

The per-IP window is fleet-wide (register row 52). It used to be a process-local ConcurrentDictionary on each central node, which had two consequences worth naming: the effective fleet cap was N × MaxUnlockAttemptsPerIpPerHour per client (a client that failed over to the other node got a fresh budget), and a node restart cleared the window outright. The window now lives in the central MS SQL BundleUnlockAttempt table — the HA store both central nodes already share, so this needs no new infrastructure, no new port and no consensus protocol. BundleUnlockRateLimiter keeps the policy; SqlBundleUnlockAttemptWindow is the store, resolving a scoped IBundleUnlockAttemptRepository from its own scope per call (it is a singleton, like the limiter). Three properties are load-bearing:

  • It fails OPEN. A store error logs a Warning naming the client and the cap, and permits the attempt. This is a best-effort rate cap layered over a surface that is already authenticated and Administrator-gated, so the availability of legitimate imports outranks the cap — a database blip must not make the import wizard unusable. Falling back to the old process-local window instead was considered and rejected: it would still block a legitimate operator's 11th import during an outage, which is precisely the outcome fail-open exists to prevent. The Warning is the operator's signal that the fleet cap is not currently being enforced.
  • Accuracy is deliberately approximate. The count-then-insert pair is not atomic across the two nodes, so genuinely simultaneous attempts can overshoot the cap by up to the number in flight. A conditional single-statement insert would close that at the cost of provider-specific raw SQL; the overshoot is bounded and self-corrects within the window.
  • Rows are pruned opportunistically, not by a background service. Transport owns no central scheduler, so the prune rides the unlock path: claimed by CAS at most once per 15 minutes per process, bounded to 1000 rows per pass, retaining 2 × the window so a prune racing a count can never delete a row the count still needs, and wrapped in a try/catch that logs and swallows — retention maintenance must never fail the operator's actual unlock. This is the same shape DeploymentService uses for terminal deployment records.

Behaviour the operator sees is otherwise unchanged: same config key, same semantics ("attempts, not failures"), same ordering relative to the per-bundle lockout, same <unattributed> bucket.

The client key is the caller's remote IP: the Central UI wizard reads it from CircuitClientAddressProvider (a scoped service that snapshots Connection.RemoteIpAddress at circuit establishment, since HttpContext is gone by the time a circuit submits a passphrase — the same pattern as CookieAuthenticationStateProvider), and the management endpoint stamps it on ManagementEnvelope.ClientIp for the CLI transport preview / transport import paths. A caller with no attributable address is counted against a single shared bucket rather than skipping the throttle — "unknown client" degrades to a stricter budget, never to none. As with the login throttle (Component-Security.md), per-client isolation is only real when ForwardedHeaders is configured for the fronting proxy; behind an untrusted proxy every caller collapses into the proxy's bucket.

// From BundleImporter.LoadAsync — decrypt path (simplified)
var aad = Encryption.BundleManifestAad.Compute(manifest);
try
{
    decryptedContent = _encryptor.Decrypt(contentBytes, manifest.Encryption, passphrase, aad);
}
catch (CryptographicException)
{
    var newCount = _sessionStore.IncrementUnlockFailureCount(manifest.ContentHash);
    if (newCount >= maxAttempts)
        throw new BundleLockedException(manifest.ContentHash, newCount);
    throw;
}
_sessionStore.ClearUnlockFailures(manifest.ContentHash);

Phase 2 — PreviewAsync: deserializes the session's plaintext bytes to BundleContentDto and calls ArtifactDiff.Compare* methods for each entity type. Diff results use ConflictKind (Identical, Modified, New, Blocker). Modified items carry a FieldDiffJson payload with changed-field names and old/new values; script bodies record a line-count delta rather than full text to keep the diff compact. DetectBlockersAsync scans script bodies for unresolvable SharedScript or ExternalSystem name references.

Phase 3 — ApplyAsync: runs semantic validation first (a name-resolution scan plus the full SemanticValidator from TemplateEngine), then applies all resolutions inside one EF transaction. The correlation GUID is set on IAuditCorrelationContext.BundleImportId before any writes so that every IAuditService.LogAsync call during the apply picks it up automatically. Three SaveChangesAsync calls handle forward references: an intermediate flush inside ApplyTemplatesAsync materializes folder identity values so that template FolderId foreign keys can be wired correctly; a second flush after all Apply* helpers materializes row identities before ResolveAlarmScriptLinksAsync and ResolveCompositionEdgesAsync run; a third flush commits the BundleImported audit row just before CommitAsync. All three flushes operate inside the same outer transaction. On failure, the transaction rolls back, BundleImportId is cleared, and a BundleImportFailed row is written outside the rolled-back transaction before the exception propagates.

// From BundleImporter.ApplyAsync — correlation + transaction pattern
_correlationContext.BundleImportId = bundleImportId;
await using var tx = await _dbContext.Database.BeginTransactionAsync(ct);
try
{
    var errors = await RunSemanticValidationAsync(content, resolutionMap, ct);
    if (errors.Count > 0) throw new SemanticValidationException(errors);

    await ApplyTemplateFoldersAsync(/* ... */);
    await ApplyTemplatesAsync(/* ... */);
    // ... other entity types ...
    await _dbContext.SaveChangesAsync(ct);     // flush for FK resolution
    await ResolveAlarmScriptLinksAsync(/* ... */);
    await ResolveCompositionEdgesAsync(/* ... */);

    await _auditService.LogAsync(user, "BundleImported", "Bundle",
        bundleImportId.ToString(), session.Manifest.SourceEnvironment, /* afterState */, ct);
    await _dbContext.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);
    ZeroDecryptedContent(session);
    _sessionStore.Remove(sessionId);
    return new ImportResult(BundleImportId: bundleImportId, /* counts */);
}
catch
{
    await tx.RollbackAsync(ct);
    _correlationContext.BundleImportId = null;
    await _auditService.LogAsync(user, "BundleImportFailed", /* ... */);
    ZeroDecryptedContent(session);
    _sessionStore.Remove(sessionId);
    throw;
}

Usage

The Central UI surfaces Transport through two wizard pages (see Central UI):

  • Export (/design/transport/export, Design role): a 4-step wizard — select artifacts, review resolved dependencies, set a passphrase, download the .scadabundle file.
  • Import (/design/transport/import, Admin role): a 5-step wizard — upload bundle, enter passphrase, review the diff and set per-artifact resolutions, confirm (operator types the source environment name), view the result and navigate to the Deployments page for any newly stale instances.

The same operations are available via the CLI:

scadabridge bundle export  --output FILE --passphrase X [--templates A,B] \
                            [--include-dependencies] [--source-environment NAME]

scadabridge bundle preview --input FILE --passphrase X

scadabridge bundle import  --input FILE --passphrase X [--on-conflict skip|overwrite|rename]

CLI commands route through ManagementActor handlers (ExportBundleCommand, PreviewBundleCommand, ImportBundleCommand), which delegate to the same IBundleExporter / IBundleImporter scoped services. Bundle bytes ride the existing /management JSON envelope as base64.

After import, template changes propagate to deployed instances through revision-hash drift detection in DeploymentService.CompareAsync. Transport does not write a stale marker — the existing Deployments page surfaces affected instances automatically.

Configuration

TransportOptions is bound from the ScadaBridge:Transport section.

Key Default Description
SourceEnvironment "scadabridge" Environment label stamped in manifest.json and used in export filenames.
SchemaVersionMajor 1 Major schema version stamped in exported manifests. Not read by the importer; import version-gating uses ManifestBuilder.CurrentBundleFormatVersion directly.
BundleSessionTtlMinutes 30 TTL for an in-progress import session.
MaxBundleSizeMb 100 Upload size cap; enforced before any decompression.
MaxBundleEntryCount 4 Maximum ZIP entries (a valid bundle has exactly 2).
MaxBundleEntryDecompressedMb 200 Per-entry decompressed size cap (ZIP-bomb defence).
MaxBundleEntryCompressionRatio 50 Per-entry compression ratio cap (ZIP-bomb defence).
MaxUnlockAttemptsPerSession 3 Per-bundle passphrase strike limit.
MaxUnlockAttemptsPerIpPerHour 10 Per-IP trailing-hour unlock attempts.
Pbkdf2Iterations 600000 PBKDF2-SHA256 iteration count for key derivation.

SourceEnvironment should be set per environment (e.g., dev-cluster, prod-cluster) so the import wizard's confirmation gate works correctly.

Dependencies & Interactions

  • Commons (#16) — owns BundleManifest, ExportSelection, ImportPreview, ImportResolution, ImportResult, BundleSession, EncryptionMetadata, BundleSummary, ManifestContentEntry, ConflictKind, and the IBundleExporter, IBundleImporter, IBundleSessionStore, IAuditCorrelationContext interfaces. Transport implementations bind to these contracts; Commons defines nothing Transport-specific beyond the DTOs and interfaces.
  • Configuration Database (#17) — supplies all repository implementations (ITemplateEngineRepository, IExternalSystemRepository, INotificationRepository, IInboundApiRepository), IAuditService for per-entity audit rows, the IAuditCorrelationContext implementation (AuditCorrelationContext) registered as scoped, the ScadaBridgeDbContext used for the import transaction, and the EF migration that adds BundleImportId uniqueidentifier NULL (with index IX_AuditLogEntries_BundleImportId) to AuditLogEntries.
  • Template Engine (#1) — provides SemanticValidator, invoked inside ApplyAsync before the transaction commits. The importer feeds each imported TemplateDto through the validator alongside the combined in-bundle + pre-existing SharedScript catalog; validation errors surface as SemanticValidationException and roll back the entire import.
  • Audit Log / Configuration Audit — every export produces a BundleExported or UnencryptedBundleExport row; every import produces a BundleImported summary row (or BundleImportFailed on rollback). Per-entity rows written by Apply* helpers carry BundleImportId so operators can query all configuration changes from a single import as a group. BundleImportUnlockFailed rows are written on passphrase failures. Warning rows BundleImportAlarmScriptUnresolved and BundleImportCompositionUnresolved are written when second-pass FK rewire cannot resolve a name.
  • Central UI — hosts the Export Bundle page under the Design nav group and the Import Bundle page under the Admin nav group; the import result page links to the Deployments page and to the filtered Configuration Audit Log Viewer pre-populated with the completed BundleImportId.
  • Security & Auth (#10) — enforces RequireDesign on export and RequireAdmin on import, both at the Razor page layer and inside the IBundleExporter / IBundleImporter service entrypoints (defense in depth).
  • Deployment Manager (#2) — not directly called by Transport; template overwrites naturally change the flattened-config hash that DeploymentService.CompareAsync reads, causing affected instances to surface as stale on the Deployments page.

Troubleshooting

Bundle upload rejected at format version check

LoadAsync throws NotSupportedException when manifest.json carries a bundleFormatVersion that does not equal ManifestBuilder.CurrentBundleFormatVersion (currently 1). Any non-matching value — whether higher or lower — is rejected. Upgrade the target cluster or re-export from a version that produces format version 1.

Content hash mismatch on upload

LoadAsync throws InvalidDataException("Bundle content hash does not match manifest — file may be corrupt."). The ZIP was corrupted in transit. Compare the SHA-256 shown in the export wizard's Step 4 against the downloaded file and re-export if they differ.

Session expired between diff and apply

PreviewAsync or ApplyAsync throws when the BundleSession is not found. The 30-minute TTL elapsed while the operator was reviewing the diff. Re-upload the bundle to start a new session.

Apply rolls back with SemanticValidationException

A template's scripts reference a SharedScript or ExternalSystem that exists neither in the bundle nor in the target environment, or a type mismatch exists in a call argument. The exception lists per-template errors. Either re-export with the missing dependency included, or pre-create the missing artifact in the target environment before importing.

Passphrase lockout

After 3 wrong passphrase attempts against the same bundle (keyed by ContentHash), BundleImporter.LoadAsync throws BundleLockedException. The session is unusable. Re-upload the bundle file to get a new session with a fresh counter. A BundleImportUnlockFailed audit row is written on each failed attempt. Both the Central UI and the CLI surface the lockout reason verbatim — the exception lives in Commons/Types/Transport/ so ManagementActor can curate it into a ManagementCommandException (register row 51).

Unlock throttled ("Too many bundle unlock attempts")

BundleUnlockThrottledException — this client (not this bundle) has used its MaxUnlockAttemptsPerIpPerHour budget within the trailing hour. Re-uploading does not help; the budget is keyed by IP, rolls forward on a sliding window, and is fleet-wide and restart-durable (register row 52 — see below), so failing over to the other central node does not help either. The denial burns no per-bundle strike, so the bundle itself is still importable once the window frees up. Raise ScadaBridge:Transport:MaxUnlockAttemptsPerIpPerHour if a legitimate workflow (many encrypted bundles in one sitting) trips it routinely.

BundleImportAlarmScriptUnresolved / BundleImportCompositionUnresolved warnings

These audit rows appear when the second-pass rewire (ResolveAlarmScriptLinksAsync / ResolveCompositionEdgesAsync) cannot match a name to a persisted row. The import commits — the FK is left null / the composition row is skipped — but the warning signals an incomplete import. Re-examine the bundle's dependency graph and re-export with the missing artifacts included.