docs(comments): strip internal task/milestone/bundle bookkeeping from code comments

Remove project bookkeeping citations from shipped code comments across the
solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/
issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels,
and C/D/K/S/T phase labels.

Comment text only — no code logic, string/log literals, or XML-doc structure
changed. Genuine descriptions are preserved (only the citation is stripped),
and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365,
UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker
TaskReferenceInComment / TrackingReferenceInComment checks plus targeted
grep passes; full solution builds clean, append-only guard tests pass.
This commit is contained in:
Joseph Doherty
2026-07-07 11:03:26 -04:00
parent 67005ca4c0
commit 9cff87fe85
435 changed files with 2338 additions and 2547 deletions
@@ -73,7 +73,7 @@ public sealed class BundleExporter : IBundleExporter
var resolved = await _resolver.ResolveAsync(selection, cancellationToken).ConfigureAwait(false);
// 2. Convert to the wire-shaped DTO (strips EF identity, refs-by-name).
// M8 (B4): the resolver's site/dataConnection/instance closure is wired
// The resolver's site/dataConnection/instance closure is wired
// through via object-initializer — without this the aggregate would
// carry the empty defaults and the bundle would ship empty site/
// instance arrays even when those were selected (review item I3).
@@ -86,16 +86,16 @@ public sealed class BundleExporter : IBundleExporter
DatabaseConnections: resolved.DatabaseConnections,
NotificationLists: resolved.NotificationLists,
SmtpConfigurations: resolved.SmtpConfigs,
// Inbound API keys are not transported between environments (re-arch C4).
// Inbound API keys are not transported between environments.
ApiMethods: resolved.ApiMethods)
{
Sites = resolved.Sites,
DataConnections = resolved.DataConnections,
Instances = resolved.Instances,
// SMS (S10b): wire the resolver's SMS config closure through the
// Wire the resolver's SMS config closure through the
// aggregate. Without this the aggregate carries the empty default and the
// bundle ships an empty smsConfigs array even when SMS configs were
// selected — mirrors the M8 site/instance fix (review item I3).
// selected — mirrors the site/instance fix (review item I3).
SmsConfigurations = resolved.SmsConfigs,
};
var contentDto = _entitySerializer.ToBundleContent(aggregate);
@@ -110,7 +110,7 @@ public sealed class BundleExporter : IBundleExporter
NotificationLists: resolved.NotificationLists.Count,
SmtpConfigs: resolved.SmtpConfigs.Count,
ApiMethods: resolved.ApiMethods.Count,
// M8 (B3): additive site/dataConnection/instance counts. Sourced from
// Additive site/dataConnection/instance counts. Sourced from
// the serialized content DTO — the same arrays the importer reads —
// so the manifest summary always matches the packed payload. These
// arrays default to empty until B1/B2 populate them, so the counts
@@ -118,7 +118,7 @@ public sealed class BundleExporter : IBundleExporter
Sites: contentDto.Sites.Count,
DataConnections: contentDto.DataConnections.Count,
Instances: contentDto.Instances.Count,
// SMS (S10b): additive count sourced from the serialized content DTO — the
// Additive count sourced from the serialized content DTO — the
// same array the importer reads — so the manifest summary always matches
// the packed payload.
SmsConfigs: contentDto.SmsConfigs.Count);
@@ -25,7 +25,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Export;
/// Templates are returned topologically sorted (base-before-derived) via Kahn's
/// algorithm so importers can apply them in order.
/// <para>
/// M8 adds site/instance-scoped expansion: selecting a site pulls its
/// Site/instance-scoped expansion: selecting a site pulls its
/// <c>DataConnection</c>s and <c>Instance</c>s; selecting an instance (with
/// <see cref="ExportSelection.IncludeDependencies"/>) pulls its owning site, the
/// data connections it binds, and feeds its template into the existing template
@@ -111,7 +111,7 @@ public sealed class DependencyResolver
if (sm is not null) smtpConfigs[sm.Id] = sm;
}
// SMS provider configs (S10b): mirror the SMTP seed exactly — resolve each
// SMS provider configs: mirror the SMTP seed exactly — resolve each
// selected id via the by-id repository accessor; missing ids are skipped.
var smsConfigs = new Dictionary<int, SmsConfiguration>();
foreach (var id in selection.SmsConfigurationIds.Distinct())
@@ -121,7 +121,7 @@ public sealed class DependencyResolver
}
// Inbound API keys are intentionally NOT resolved into the bundle: per the
// inbound-API-key re-architecture (C4) keys are not transported between
// inbound-API-key re-architecture, keys are not transported between
// environments. Only API methods travel.
var apiMethods = new Dictionary<int, ApiMethod>();
foreach (var id in selection.ApiMethodIds.Distinct())
@@ -130,7 +130,7 @@ public sealed class DependencyResolver
if (m is not null) apiMethods[m.Id] = m;
}
// ---- Seed: site/instance-scoped selection (M8) ----
// ---- Seed: site/instance-scoped selection ----
// sites/dataConnections/instances are keyed by surrogate id for dedup.
var sites = new Dictionary<int, Site>();
var dataConnections = new Dictionary<int, DataConnection>();
@@ -200,7 +200,7 @@ public sealed class DependencyResolver
// ---- Topological sort of templates (base-before-derived) ----
var orderedTemplates = TopologicallySortTemplates(templates.Values);
// ---- Deterministic site/instance ordering (M8) ----
// ---- Deterministic site/instance ordering ----
// siteId → SiteIdentifier map: every connection's/instance's owning site lands
// in `sites` after closure expansion, so ordering + dependsOn edges resolve names.
var siteIdentifierById = sites.Values.ToDictionary(s => s.Id, s => s.SiteIdentifier);
@@ -266,13 +266,13 @@ public sealed class DependencyResolver
Sites = orderedSites,
DataConnections = orderedDataConnections,
Instances = orderedInstances,
// SMS (S10b): ordered by AccountSid — the natural key the importer matches
// SMS: ordered by AccountSid — the natural key the importer matches
// on, mirroring how SMTP is ordered by Host.
SmsConfigs = smsConfigs.Values.OrderBy(s => s.AccountSid, StringComparer.Ordinal).ToList(),
};
}
// ---- Instance child loading (M8) ----
// ---- Instance child loading ----
// Loads an instance and attaches its overrides/bindings onto the entity's own
// navigation collections, so the serializer reads children off the entity (the
// same shape central-config entities use). Returns null if the instance is gone.
@@ -293,7 +293,7 @@ public sealed class DependencyResolver
return instance;
}
// ---- Site/instance closure (M8) ----
// ---- Site/instance closure ----
// For every gathered instance: ensure its owning Site is in the bundle, include
// the DataConnections it binds (via ConnectionBinding.DataConnectionId resolved
// within the instance's site, plus NativeAlarmSourceOverride.ConnectionNameOverride
@@ -618,19 +618,19 @@ public sealed class DependencyResolver
{
entries.Add(new ManifestContentEntry("SmtpConfiguration", s.Host, 1, Array.Empty<string>()));
}
// SMS (S10b): mirror SMTP — one manifest row per config, keyed by AccountSid
// SMS: mirror SMTP — one manifest row per config, keyed by AccountSid
// (the natural key the importer matches on).
foreach (var s in smsConfigs.OrderBy(x => x.AccountSid, StringComparer.Ordinal))
{
entries.Add(new ManifestContentEntry("SmsConfiguration", s.AccountSid, 1, Array.Empty<string>()));
}
// Inbound API keys are not transported (re-arch C4) — no ApiKey manifest entries.
// Inbound API keys are not transported — no ApiKey manifest entries.
foreach (var m in apiMethods.OrderBy(x => x.Name, StringComparer.Ordinal))
{
entries.Add(new ManifestContentEntry("ApiMethod", m.Name, 1, Array.Empty<string>()));
}
// ---- M8: site/instance-scoped manifest rows ----
// ---- Site/instance-scoped manifest rows ----
// Sites are roots (no dependsOn). DataConnections depend on their site.
// Instances depend on their template, their site, and each data connection
// they bind. Inputs arrive pre-ordered, so iterate as-is for determinism.
@@ -25,11 +25,11 @@ public sealed record ResolvedExport(
IReadOnlyList<DatabaseConnectionDefinition> DatabaseConnections,
IReadOnlyList<NotificationList> NotificationLists,
IReadOnlyList<SmtpConfiguration> SmtpConfigs,
// Inbound API keys are not transported between environments (re-arch C4); only methods.
// Inbound API keys are not transported between environments; only methods.
IReadOnlyList<ApiMethod> ApiMethods,
IReadOnlyList<ManifestContentEntry> ContentManifest)
{
// M8: site/instance-scoped closure. Additive init-only collections with empty
// Site/instance-scoped closure. Additive init-only collections with empty
// defaults so the positional ctor stays source-compatible for callers/tests
// that only resolve central-config selections (sites/instances stay empty).
// The resolver opts in via object-initializer. Each <see cref="Instance"/>
@@ -59,7 +59,7 @@ public sealed record ResolvedExport(
/// <summary>
/// SMS provider configurations in the closure, ordered by
/// <see cref="SmsConfiguration.AccountSid"/> (S10b). Mirrors
/// <see cref="SmsConfiguration.AccountSid"/>. Mirrors
/// <see cref="SmtpConfigs"/>; init-only with an empty default so callers that
/// only resolve other selections keep compiling.
/// </summary>
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// "Coarse" means: each persistent field is compared as a value; differing
/// non-code fields appear in <c>changes</c> with old/new values. Code / large-
/// text fields (script bodies, API-method scripts) instead carry a structured
/// per-line Myers diff (T20): the <see cref="FieldChange.OldValue"/> /
/// per-line Myers diff: the <see cref="FieldChange.OldValue"/> /
/// <see cref="FieldChange.NewValue"/> keep a compact <c>&lt;N lines&gt;</c>
/// summary for fallback rendering, and a <see cref="FieldChange.LineDiff"/>
/// payload carries the hunk lines (+/-/context) plus add/remove totals and a
@@ -210,7 +210,7 @@ public sealed class ArtifactDiff
incoming.Recipients,
e => e.Name,
i => i.Name,
// S10: a recipient is unchanged only when BOTH contacts match — a phone
// A recipient is unchanged only when BOTH contacts match — a phone
// number added/changed on an existing recipient is a real diff.
(e, i) => e.EmailAddress == i.EmailAddress && e.PhoneNumber == i.PhoneNumber,
"Recipients",
@@ -254,7 +254,7 @@ public sealed class ArtifactDiff
/// <summary>
/// Compares an incoming SMS provider configuration against the existing one in
/// the database (S10b). Mirrors <see cref="CompareSmtpConfiguration"/>: keyed by
/// the database. Mirrors <see cref="CompareSmtpConfiguration"/>: keyed by
/// the natural key <c>AccountSid</c>, with the auth token diffed presence-only
/// (it lives in <see cref="SecretsBlock"/>, never compared by value).
/// </summary>
@@ -286,7 +286,7 @@ public sealed class ArtifactDiff
return BuildItem("SmsConfiguration", incoming.AccountSid, changes);
}
// CompareApiKey was removed in re-arch C4: inbound API keys are not transported
// CompareApiKey was removed: inbound API keys are not transported
// between environments, so the import preview never diffs keys.
/// <summary>
@@ -301,7 +301,7 @@ public sealed class ArtifactDiff
if (existing is null) return New("ApiMethod", incoming.Name);
var changes = new List<FieldChange>();
// ApprovedApiKeyIds is not transported (re-arch C4) and is excluded from the diff.
// ApprovedApiKeyIds is not transported and is excluded from the diff.
AddIfDifferent(changes, "ParameterDefinitions", existing.ParameterDefinitions, incoming.ParameterDefinitions);
AddIfDifferent(changes, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition);
AddIfDifferent(changes, "TimeoutSeconds", existing.TimeoutSeconds, incoming.TimeoutSeconds);
@@ -329,7 +329,7 @@ public sealed class ArtifactDiff
return BuildItem("TemplateFolder", incoming.Name, changes);
}
// ---- Site / Connection / Instance (M8) ----
// ---- Site / Connection / Instance ----
/// <summary>
/// Compares an incoming site against the existing site in the database.
@@ -504,8 +504,8 @@ public sealed class ArtifactDiff
private static void AddCodeChangeIfDifferent(List<FieldChange> changes, string field, string? existing, string? incoming)
{
// Code/large-text fields: when they differ, emit a real per-line Myers diff
// (T20). The OldValue/NewValue keep a compact "<N lines>" summary as a
// Code/large-text fields: when they differ, emit a real per-line Myers diff.
// The OldValue/NewValue keep a compact "<N lines>" summary as a
// fallback for renderers that don't consume the structured payload; the
// LineDiff carries the +/- hunk lines. Identical code emits no change.
var sameNullness = existing is null == incoming is null;
@@ -635,7 +635,7 @@ public sealed class ArtifactDiff
if (!ScriptsEqual(ex, inc))
{
// A script row can diverge in Code and/or its trigger/param metadata.
// The structured line diff covers the Code body (T20); when only the
// The structured line diff covers the Code body; when only the
// metadata changed the hunk list is all-context (no +/-), which still
// tells the operator the row changed.
changes.Add(BuildCodeFieldChange($"Scripts.{name}", ex.Code, inc.Code));
@@ -696,7 +696,7 @@ public sealed class ArtifactDiff
[property: JsonPropertyName("field")] string Field,
[property: JsonPropertyName("oldValue")] string? OldValue,
[property: JsonPropertyName("newValue")] string? NewValue,
// Present only for code / large-text fields (T20). Null (and omitted from
// Present only for code / large-text fields. Null (and omitted from
// the JSON by the WhenWritingNull policy) for ordinary coarse fields.
[property: JsonPropertyName("lineDiff")] LineDiffPayload? LineDiff = null);
@@ -49,7 +49,7 @@ public sealed class BundleImporter : IBundleImporter
{
// All bundle content deserialization goes through BundleJsonOptions.Default.
// IMPORTANT — unknown JSON properties must remain ALLOWED (the default
// JsonUnmappedMemberHandling.Skip). Pre-C4 bundles may carry a top-level
// JsonUnmappedMemberHandling.Skip). Legacy bundles may carry a top-level
// "apiKeys" array and/or "ApprovedApiKeyIds" inside "apiMethods[]" entries.
// Setting JsonUnmappedMemberHandling.Disallow here would cause those bundles
// to fail deserialization, breaking backward-compat. This tolerance is
@@ -74,7 +74,7 @@ public sealed class BundleImporter : IBundleImporter
private readonly IOptions<TransportOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly SemanticValidator _semanticValidator;
// #16 (M8 D2): optional. Implemented in DeploymentManager (where the flattening
// Optional. Implemented in DeploymentManager (where the flattening
// pipeline lives) and surfaced via the Commons IStaleInstanceProbe seam. Null
// in a host that registers Transport without DeploymentManager — staleness is
// then best-effort empty (informational only, never gates the import).
@@ -96,12 +96,12 @@ public sealed class BundleImporter : IBundleImporter
/// <param name="externalRepo">External system repository for diff and apply.</param>
/// <param name="notificationRepo">Notification repository for diff and apply.</param>
/// <param name="inboundApiRepo">Inbound API repository for diff and apply.</param>
/// <param name="siteRepo">Site repository — supplies the target sites and site-scoped data connections that the preview's site/connection auto-match resolves against (M8 C2).</param>
/// <param name="siteRepo">Site repository — supplies the target sites and site-scoped data connections that the preview's site/connection auto-match resolves against.</param>
/// <param name="auditService">Audit service for writing per-entity import audit rows.</param>
/// <param name="correlationContext">Correlation context that carries the active BundleImportId.</param>
/// <param name="dbContext">EF Core context used to commit the import transaction.</param>
/// <param name="semanticValidator">Validates template references before applying.</param>
/// <param name="staleInstanceProbe">Optional (#16): recomputes a deployed instance's current revision hash so the importer can enumerate instances stale-ed by a template overwrite. Null when no flattening pipeline is registered (Transport-without-DeploymentManager hosts) — staleness is then skipped.</param>
/// <param name="staleInstanceProbe">Optional: recomputes a deployed instance's current revision hash so the importer can enumerate instances stale-ed by a template overwrite. Null when no flattening pipeline is registered (Transport-without-DeploymentManager hosts) — staleness is then skipped.</param>
/// <param name="logger">Optional logger.</param>
public BundleImporter(
BundleSerializer bundleSerializer,
@@ -371,7 +371,7 @@ public sealed class BundleImporter : IBundleImporter
}
// ---- Templates ----
// Transport-008: previously this loop iterated GetAllTemplatesAsync()
// Previously this loop iterated GetAllTemplatesAsync()
// and called GetTemplateWithChildrenAsync(stub.Id) once per matching
// name (classic N+1). The bulk variant fetches every matching template
// with children eager-loaded in a single round-trip.
@@ -429,7 +429,7 @@ public sealed class BundleImporter : IBundleImporter
items.Add(_diff.CompareSmtpConfiguration(sm, existing));
}
// ---- SmsConfigurations (S10b; no by-AccountSid lookup — scan GetAll) ----
// ---- SmsConfigurations (no by-AccountSid lookup — scan GetAll) ----
var allSms = await _notificationRepo.GetAllSmsConfigurationsAsync(ct).ConfigureAwait(false);
var smsBySid = allSms.ToDictionary(s => s.AccountSid, s => s, StringComparer.Ordinal);
foreach (var sms in content.SmsConfigs)
@@ -439,8 +439,8 @@ public sealed class BundleImporter : IBundleImporter
}
// ---- ApiKeys ----
// Inbound API keys are not transported between environments (re-arch C4).
// New bundles never carry a keys section. A pre-C4 bundle may still contain
// Inbound API keys are not transported between environments.
// New bundles never carry a keys section. A legacy bundle may still contain
// one; we do NOT surface those keys as importable preview rows (they would
// offer Add/Overwrite actions for keys that can't be meaningfully re-created
// from a hash). They are counted, ignored, and reported at apply time.
@@ -452,7 +452,7 @@ public sealed class BundleImporter : IBundleImporter
items.Add(_diff.CompareApiMethod(m, existing));
}
// ---- M8 site/instance-scoped types ----
// ---- Site/instance-scoped types ----
// Sites/DataConnections/Instances are referenced by stable string identity
// (SiteIdentifier / connection Name / UniqueName) and resolved against the
// TARGET environment's own surrogate keys. We auto-match the source site by
@@ -493,7 +493,7 @@ public sealed class BundleImporter : IBundleImporter
}
// ---- DataConnections (site-scoped; matched by name within the auto-matched target site) ----
// C2: connection names are unique only WITHIN a site, so the preview item's
// Connection names are unique only WITHIN a site, so the preview item's
// identity is SITE-QUALIFIED (`{SiteIdentifier}/{Name}`). The diff CONTENT is
// unchanged — only the item Name is qualified (after CompareDataConnection
// returns) so two sites' same-named connections resolve to distinct items and
@@ -543,7 +543,7 @@ public sealed class BundleImporter : IBundleImporter
instDto, existing, existingTemplateName, existingSiteIdentifier, existingAreaName));
}
// ---- Required site/connection mappings (M8) ----
// ---- Required site/connection mappings ----
var (requiredSites, requiredConnections) = await BuildRequiredMappingsAsync(
content, ResolveTargetSiteAsync, ResolveTargetConnectionsAsync).ConfigureAwait(false);
@@ -556,7 +556,7 @@ public sealed class BundleImporter : IBundleImporter
/// <summary>
/// Collects the distinct set of source sites and (site, connection) pairs the
/// bundle references, auto-matching each against the TARGET environment by
/// identity, and returns the operator-facing required-mapping lists (M8 C2).
/// identity, and returns the operator-facing required-mapping lists.
/// <para>
/// Site references are drawn from every instance, every site, and every
/// data-connection in the bundle. Connection references are drawn from every
@@ -666,7 +666,7 @@ public sealed class BundleImporter : IBundleImporter
{
var blockers = new List<ImportPreviewItem>();
// ---- M8: instance template + referenced-connection blockers ----
// ---- Instance template + referenced-connection blockers ----
// The set of template names the import can satisfy = (in-bundle templates)
// (templates already in the target DB). An instance whose TemplateName is
// in neither is unresolvable.
@@ -735,7 +735,7 @@ public sealed class BundleImporter : IBundleImporter
if (bundleConnections.Contains((site, name))) continue;
var targetConns = await resolveTargetConnections(site).ConfigureAwait(false);
if (targetConns.Any(c => string.Equals(c.Name, name, StringComparison.Ordinal))) continue;
// C2: site-qualify the blocker Name so two sites' same-named-but-missing
// Site-qualify the blocker Name so two sites' same-named-but-missing
// connections surface as distinct blockers (a bare name would collide).
blockers.Add(new ImportPreviewItem(
EntityType: "Instance",
@@ -888,7 +888,7 @@ public sealed class BundleImporter : IBundleImporter
private static bool IsIdentifierChar(char c) => c == '_' || char.IsLetterOrDigit(c);
/// <summary>
/// C2: the site-qualified identity (<c>{siteIdentifier}/{connectionName}</c>) used
/// The site-qualified identity (<c>{siteIdentifier}/{connectionName}</c>) used
/// for every <c>DataConnection</c> preview item Name + blocker Name, and for the
/// matching resolution lookup in <see cref="ApplyDataConnectionsAsync"/>. Connection
/// names are unique only WITHIN a site, so a bare-name key would collapse two sites'
@@ -939,8 +939,8 @@ public sealed class BundleImporter : IBundleImporter
r => r);
var summary = new ImportSummary();
// Inbound API keys are not transported between environments (re-arch C4).
// A pre-C4 bundle may still contain a keys section; we ignore those keys
// Inbound API keys are not transported between environments.
// A legacy bundle may still contain a keys section; we ignore those keys
// entirely (never re-create them) but count them so the result can tell
// the operator to re-issue keys on this environment.
var apiKeysIgnored = content.ApiKeys?.Count ?? 0;
@@ -965,7 +965,7 @@ public sealed class BundleImporter : IBundleImporter
// pre-existing target reads, so it has no ordering dependency on
// the Apply* helpers. Failing here means the change tracker is
// still empty, which keeps the rollback contract simple on both
// the in-memory and relational providers (FU-B: intermediate
// the in-memory and relational providers (intermediate
// SaveChangesAsync between Apply* and the second-pass rewire
// would otherwise prevent the in-memory provider from undoing
// already-flushed template rows on validation failure — the
@@ -976,7 +976,7 @@ public sealed class BundleImporter : IBundleImporter
// a Skip on a dependency surfaces as a missing-reference error
// rather than silently passing.
var validationErrors = await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
// M8: validate every site / connection / template reference the
// Validate every site / connection / template reference the
// site-instance payload depends on BEFORE any row is staged. Running
// this in the validation phase (not as an apply-pass guard) preserves
// the rollback contract: a structurally-unresolvable bundle fails with
@@ -992,7 +992,7 @@ public sealed class BundleImporter : IBundleImporter
throw new SemanticValidationException(validationErrors);
}
// ---- M8 site/instance-scoped apply: sites + connections FIRST ----
// ---- Site/instance-scoped apply: sites + connections FIRST ----
// Sites are the FK target for both data connections (SiteId) and
// instances (SiteId); data connections are the FK target for instance
// connection bindings (DataConnectionId) and the rewrite target for
@@ -1020,11 +1020,11 @@ public sealed class BundleImporter : IBundleImporter
await ApplyNotificationListsAsync(content.NotificationLists, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySmtpConfigsAsync(content.SmtpConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySmsConfigsAsync(content.SmsConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false);
// Inbound API keys are NOT applied from a bundle (re-arch C4) — any keys
// Inbound API keys are NOT applied from a bundle — any keys
// in a legacy bundle were counted above (apiKeysIgnored) and are skipped.
await ApplyApiMethodsAsync(content.ApiMethods, resolutionMap, user, summary, ct).ConfigureAwait(false);
// FU-B / #39 + remainder of #37 — second-pass rewire of name-keyed
// Second-pass rewire of name-keyed
// FKs that can only be resolved AFTER every template's scripts and
// child rows have been staged. We flush here so Pass A can look up
// the just-persisted scripts by name and Pass B can look up the
@@ -1043,7 +1043,7 @@ public sealed class BundleImporter : IBundleImporter
await ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
// ---- M8 site/instance-scoped apply: instances LAST ----
// ---- Site/instance-scoped apply: instances LAST ----
// The instance pass needs every FK target materialised first:
// • template id — resolved by name against the just-flushed central
// config (Templates were flushed above for the alarm/composition
@@ -1058,9 +1058,9 @@ public sealed class BundleImporter : IBundleImporter
content, resolutionMap, siteBySourceIdentifier, connectionMaps,
user, summary, ct).ConfigureAwait(false);
// ---- D2 pre-commit point (#16): stale-instance computation ----
// ---- Pre-commit point: stale-instance computation ----
// Everything the import will write is staged on the change tracker at
// this point but NOT yet committed. D2 computes StaleInstanceIds here
// this point but NOT yet committed. This computes StaleInstanceIds here
// — target instances whose template was overwritten by this import and
// therefore now carry a stale flattened-config revision — and threads
// the resulting list into the ImportResult below (replacing the
@@ -1088,7 +1088,7 @@ public sealed class BundleImporter : IBundleImporter
summary.Skipped,
summary.Renamed,
},
// re-arch C4: legacy inbound API keys present in the bundle that
// Legacy inbound API keys present in the bundle that
// were ignored (not transported / not re-created here).
ApiKeysIgnored = apiKeysIgnored,
},
@@ -1220,7 +1220,7 @@ public sealed class BundleImporter : IBundleImporter
}
/// <summary>
/// #16 (M8 D2): enumerates the deployed instances stale-ed by this import.
/// Enumerates the deployed instances stale-ed by this import.
/// Overwriting a template changes the flattened-config hash of every instance
/// of that template, so a deployed instance's freshly-computed revision hash
/// will no longer match the hash captured in its <c>DeployedConfigSnapshot</c>
@@ -1896,7 +1896,7 @@ public sealed class BundleImporter : IBundleImporter
}
/// <summary>
/// FU-B / remainder of #37 — Pass A of the post-template-flush rewire.
/// Pass A of the post-template-flush rewire.
/// For every imported template (Add / Overwrite / Rename) whose bundle DTO
/// carries any alarm with <c>OnTriggerScriptName</c>, look up the
/// persisted alarm + script by name on the same template and set the FK.
@@ -1970,7 +1970,7 @@ public sealed class BundleImporter : IBundleImporter
}
/// <summary>
/// FU-B / #39 — Pass B of the post-template-flush rewire. For every
/// Pass B of the post-template-flush rewire. For every
/// imported template (Add / Overwrite / Rename) whose bundle DTO carries
/// any <c>Compositions</c>, replace the persisted template's existing
/// composition rows with new ones whose <c>ComposedTemplateId</c> is
@@ -2453,7 +2453,7 @@ public sealed class BundleImporter : IBundleImporter
return list;
}
// S10: reconstruct a recipient carrying whichever contact(s) the bundle holds.
// Reconstruct a recipient carrying whichever contact(s) the bundle holds.
// Email-only and SMS-only round-trip exactly; a recipient with both keeps both.
// Build via the email ctor when an address is present (so the historical
// non-null-email path is unchanged) and additively restore the phone number;
@@ -2544,7 +2544,7 @@ public sealed class BundleImporter : IBundleImporter
target.Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null;
}
// SMS (S10b): mirrors ApplySmtpConfigsAsync exactly. SmsConfiguration is keyed by
// SMS: mirrors ApplySmtpConfigsAsync exactly. SmsConfiguration is keyed by
// AccountSid (the diff engine's natural key — analogous to SMTP's Host), so a
// Rename targets AccountSid and Overwrite matches an existing config by AccountSid.
// The provider auth token is decrypted out of the SecretsBlock via the same path
@@ -2618,9 +2618,9 @@ public sealed class BundleImporter : IBundleImporter
target.AuthToken = dto.Secrets?.Values.TryGetValue("AuthToken", out var token) == true ? token : null;
}
// ApplyApiKeysAsync was removed in re-arch C4: inbound API keys are not
// ApplyApiKeysAsync was removed: inbound API keys are not
// transported between environments, so a bundle never re-creates keys. Any keys
// present in a legacy (pre-C4) bundle are counted and ignored in ApplyAsync.
// present in a legacy bundle are counted and ignored in ApplyAsync.
private async Task ApplyApiMethodsAsync(
IReadOnlyList<ApiMethodDto> dtos,
@@ -2651,9 +2651,9 @@ public sealed class BundleImporter : IBundleImporter
}
case ResolutionAction.Overwrite when existing is not null:
existing.Script = dto.Script;
// Method→key scopes are not transported (re-arch C4) and the
// Method→key scopes are not transported and the
// ApprovedApiKeyIds column was dropped with the SQL Server ApiKey
// entity (re-arch C5): scopes are re-granted per environment in the
// entity: scopes are re-granted per environment in the
// shared ZB.MOM.WW.Auth.ApiKeys store, never via an imported bundle.
existing.ParameterDefinitions = dto.ParameterDefinitions;
existing.ReturnDefinition = dto.ReturnDefinition;
@@ -2680,9 +2680,9 @@ public sealed class BundleImporter : IBundleImporter
private static ApiMethod BuildApiMethod(ApiMethodDto dto, string? overrideName)
{
// Method→key scopes are not transported (re-arch C4); the ApprovedApiKeyIds
// column that once carried them was dropped with the SQL Server ApiKey entity
// (re-arch C5). Scopes are re-granted per environment in the shared
// Method→key scopes are not transported; the ApprovedApiKeyIds
// column that once carried them was dropped with the SQL Server ApiKey entity.
// Scopes are re-granted per environment in the shared
// ZB.MOM.WW.Auth.ApiKeys store.
return new ApiMethod(overrideName ?? dto.Name, dto.Script)
{
@@ -2693,7 +2693,7 @@ public sealed class BundleImporter : IBundleImporter
}
// ─────────────────────────────────────────────────────────────────────
// M8 D1 — site/instance-scoped apply.
// Site/instance-scoped apply.
//
// Three passes (sites → connections → instances) resolve every cross-
// environment name reference against the TARGET database's own surrogate
@@ -2716,7 +2716,7 @@ public sealed class BundleImporter : IBundleImporter
/// the same identifier resolves to <see cref="MappingAction.MapToExisting"/>,
/// otherwise <see cref="MappingAction.CreateNew"/>. <c>CreateNew</c> inserts a
/// site from the full <see cref="SiteDto"/> payload (display name, description,
/// and the verbatim Node A/B + gRPC Node A/B addresses — D3's "carry full
/// and the verbatim Node A/B + gRPC Node A/B addresses — the "carry full
/// config" decision). <c>MapToExisting</c> honours the site's
/// <see cref="ImportResolution"/>: <see cref="ResolutionAction.Skip"/> leaves
/// the target untouched; <see cref="ResolutionAction.Overwrite"/> applies the
@@ -2964,7 +2964,7 @@ public sealed class BundleImporter : IBundleImporter
}
// MapToExisting — honour the connection's own conflict resolution.
// C2: the resolution is keyed by the SITE-QUALIFIED name
// The resolution is keyed by the SITE-QUALIFIED name
// (`{SiteIdentifier}/{Name}`), matching the qualified preview-item Name —
// so a same-named connection under a different site can't pick up the
// wrong Skip/Overwrite.
@@ -2989,7 +2989,7 @@ public sealed class BundleImporter : IBundleImporter
targetNameByRef[key] = existing.Name;
}
// ---- C1 Pass 2 — referenced-but-not-carried connections ----
// ---- Pass 2 — referenced-but-not-carried connections ----
// A binding (or native-alarm-source override) can name a connection that
// exists in the TARGET database but was NOT carried in the bundle's
// DataConnections (e.g. exported without it). Preview correctly does NOT
@@ -3043,7 +3043,7 @@ public sealed class BundleImporter : IBundleImporter
}
/// <summary>
/// C1: every distinct <c>(sourceSiteIdentifier, connectionName)</c> pair an instance
/// Every distinct <c>(sourceSiteIdentifier, connectionName)</c> pair an instance
/// references — via a <see cref="InstanceConnectionBindingDto.ConnectionName"/> or a
/// non-null <see cref="InstanceNativeAlarmSourceOverrideDto.ConnectionNameOverride"/>.
/// Drives the connection-map Pass 2 that resolves references the bundle didn't carry.
@@ -3348,7 +3348,7 @@ public sealed class BundleImporter : IBundleImporter
{
continue;
}
// Resolve ConnectionName → target DataConnectionId. After the C1 Pass-2
// Resolve ConnectionName → target DataConnectionId. After Pass 2
// in ApplyDataConnectionsAsync, the map carries an entry for every
// referenced connection — carried in the bundle OR auto-matched in the
// target. A non-empty name that STILL doesn't resolve is a structural
@@ -3430,7 +3430,7 @@ public sealed class BundleImporter : IBundleImporter
}
/// <summary>
/// M8: validates every cross-environment reference the site/instance payload
/// Validates every cross-environment reference the site/instance payload
/// depends on, BEFORE any row is staged — so a structurally-unresolvable
/// bundle fails with an empty change tracker (preserving the all-or-nothing
/// rollback contract on every EF provider, including the in-memory one whose
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// Transport-004: thrown by <see cref="BundleImporter.LoadAsync"/> when the caller
/// Thrown by <see cref="BundleImporter.LoadAsync"/> when the caller
/// has exceeded the configured per-IP-per-hour unlock attempt cap
/// (<see cref="TransportOptions.MaxUnlockAttemptsPerIpPerHour"/>). The 429-equivalent
/// signal: the caller must wait for the trailing-hour window to roll forward before
@@ -3,7 +3,7 @@ using System.Collections.Concurrent;
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// Transport-004: in-memory sliding-window rate limiter for bundle-unlock passphrase
/// In-memory sliding-window rate limiter for bundle-unlock passphrase
/// attempts, keyed by client IP. The design doc (§11) declares a per-IP-per-hour cap
/// (default 10) as a brute-force defence against a stolen bundle; this class is the
/// minimal server-side implementation.
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
/// <b>Important — unknown-property tolerance:</b>
/// <see cref="JsonUnmappedMemberHandling"/> is left at its default
/// (<c>Skip</c>); setting it to <c>Disallow</c> would break backward-
/// compatible deserialization of pre-C4 bundles, which may carry a top-level
/// compatible deserialization of older bundles, which may carry a top-level
/// <c>apiKeys</c> array and/or <c>ApprovedApiKeyIds</c> fields inside
/// <c>apiMethods[]</c> entries. That tolerance is load-bearing and must be
/// preserved.
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
/// on the application side of the bundle boundary.
/// </summary>
// ApiKeys is intentionally absent: inbound API keys are not transported between
// environments (re-arch C4). The aggregate only carries API methods.
// environments. The aggregate only carries API methods.
public sealed record EntityAggregate(
IReadOnlyList<TemplateFolder> TemplateFolders,
IReadOnlyList<Template> Templates,
@@ -28,15 +28,15 @@ public sealed record EntityAggregate(
IReadOnlyList<SmtpConfiguration> SmtpConfigurations,
IReadOnlyList<ApiMethod> ApiMethods)
{
// M8: site/instance-scoped entities. Init-only with empty-array defaults so
// Site/instance-scoped entities. Init-only with empty-array defaults so
// existing positional `new EntityAggregate(...)` callers keep compiling and
// never see a null collection; new callers opt in via object-initializer.
public IReadOnlyList<Site> Sites { get; init; } = Array.Empty<Site>();
public IReadOnlyList<DataConnection> DataConnections { get; init; } = Array.Empty<DataConnection>();
public IReadOnlyList<Instance> Instances { get; init; } = Array.Empty<Instance>();
// SMS (S10): carried alongside SmtpConfigurations. Init-only with an empty
// default for the same source-compat reason as the M8 collections above —
// SMS: carried alongside SmtpConfigurations. Init-only with an empty
// default for the same source-compat reason as the site/instance collections above —
// every existing positional `new EntityAggregate(...)` caller keeps compiling
// and never sees null; producers that resolve SMS config opt in via
// object-initializer.
@@ -52,7 +52,7 @@ public sealed record EntityAggregate(
/// <para>
/// <see cref="ApiKeys"/> is a <b>legacy, read-only</b> field retained purely for
/// backward-compatible deserialization of bundles produced before the
/// inbound-API-key re-architecture (C4). New exports never populate it (it stays
/// inbound-API-key re-architecture. New exports never populate it (it stays
/// <c>null</c> and is dropped from the JSON by the serializer's
/// <c>WhenWritingNull</c> policy); the importer counts any keys present in an old
/// bundle, ignores them, and surfaces a note — keys are re-created per environment.
@@ -69,7 +69,7 @@ public sealed record BundleContentDto(
IReadOnlyList<ApiMethodDto> ApiMethods,
IReadOnlyList<ApiKeyDto>? ApiKeys = null)
{
// M8: site/instance-scoped payloads. Modeled as init-only properties with
// Site/instance-scoped payloads. Modeled as init-only properties with
// empty-array defaults (rather than additional positional ctor params) for
// two reasons:
// 1. Forward-compat: deserializing an OLDER bundle whose content blob has
@@ -85,9 +85,9 @@ public sealed record BundleContentDto(
public IReadOnlyList<DataConnectionDto> DataConnections { get; init; } = Array.Empty<DataConnectionDto>();
public IReadOnlyList<InstanceDto> Instances { get; init; } = Array.Empty<InstanceDto>();
// SMS (S10): central-only SMS provider configs, carried alongside SmtpConfigs.
// SMS: central-only SMS provider configs, carried alongside SmtpConfigs.
// Modeled as an init-only property with an empty default (NOT a positional ctor
// param) for the same two reasons as the M8 site/instance arrays above:
// param) for the same two reasons as the site/instance arrays above:
// 1. Forward-compat: deserializing a bundle written before SMS support leaves
// this at Array.Empty<SmsConfigDto>() — consumers always see an empty list,
// never null.
@@ -146,7 +146,7 @@ public sealed record TemplateScriptDto(
string? ReturnDefinition,
bool IsLocked,
TimeSpan? MinTimeBetweenRuns,
// M2.5 (#9): per-script execution timeout (seconds). Additive trailing field;
// Per-script execution timeout (seconds). Additive trailing field;
// null on bundles written before this field existed.
int? ExecutionTimeoutSeconds = null);
@@ -189,12 +189,12 @@ public sealed record NotificationListDto(
public sealed record NotificationRecipientDto(
string Name,
// S10: nullable so an SMS-only recipient (no email) round-trips faithfully
// Nullable so an SMS-only recipient (no email) round-trips faithfully
// instead of being coerced to an empty string. Email-only recipients (the
// historical case) still carry a non-null address; the entity enforces that at
// least one contact is present.
string? EmailAddress,
// S10: SMS recipients carry an E.164 phone number instead of (or in addition
// SMS recipients carry an E.164 phone number instead of (or in addition
// to) an email. Trailing + nullable so a bundle written before SMS support
// deserializes this as null (backward-compatible); the serializer's
// WhenWritingNull policy keeps it out of email-only bundles' JSON.
@@ -228,8 +228,9 @@ public sealed record SmsConfigDto(
TimeSpan RetryDelay,
SecretsBlock? Secrets);
// Legacy DTO: only deserialized from pre-C4 bundles so the importer can count and
// ignore the keys they contain. New exports never emit an ApiKeys array.
// Legacy DTO: only deserialized from bundles produced before the inbound-API-key
// re-architecture so the importer can count and ignore the keys they contain.
// New exports never emit an ApiKeys array.
public sealed record ApiKeyDto(
string Name,
string KeyHash,
@@ -237,7 +238,7 @@ public sealed record ApiKeyDto(
SecretsBlock? Secrets);
// ApprovedApiKeyIds is intentionally absent: it linked methods to keys that are no
// longer transported (re-arch C4). Scopes are re-granted per environment. The field
// longer transported. Scopes are re-granted per environment. The field
// in any old bundle is simply ignored on read.
public sealed record ApiMethodDto(
string Name,
@@ -246,7 +247,7 @@ public sealed record ApiMethodDto(
string? ReturnDefinition,
int TimeoutSeconds);
// --- M8: site/instance-scoped transport DTOs --------------------------------
// --- Site/instance-scoped transport DTOs --------------------------------
// These travel alongside the original central-config DTOs above. Sites and
// instances are referenced by their stable string identities (SiteIdentifier,
// UniqueName, TemplateName) rather than database ids so a bundle resolves
@@ -29,7 +29,7 @@ public sealed class EntitySerializer
var folderNameById = aggregate.TemplateFolders.ToDictionary(f => f.Id, f => f.Name);
var templateNameById = aggregate.Templates.ToDictionary(t => t.Id, t => t.Name);
// M8 name-link lookups. Instances/data-connections reference their owning
// Name-link lookups. Instances/data-connections reference their owning
// site by the portable SiteIdentifier (not the EF surrogate SiteId); a
// connection binding references its DataConnection by Name. Build both
// id→name maps once so the projections below resolve in O(1).
@@ -138,7 +138,7 @@ public sealed class EntitySerializer
NotificationLists: aggregate.NotificationLists.Select(nl => new NotificationListDto(
Name: nl.Name,
Type: nl.Type,
// S10: carry BOTH contacts. Keep any recipient that has at least
// Carry BOTH contacts. Keep any recipient that has at least
// one contact (email OR phone) — SMS-only recipients have a null
// EmailAddress and would have been dropped by the old email-only
// filter. EmailAddress/PhoneNumber are nullable on the DTO so a
@@ -172,7 +172,7 @@ public sealed class EntitySerializer
RetryDelay: smtp.RetryDelay,
Secrets: secrets);
}).ToList(),
// Inbound API keys are not transported between environments (re-arch C4):
// Inbound API keys are not transported between environments:
// the bundle carries API methods only. ApiMethod.ApprovedApiKeyIds is also
// excluded — it references keys that aren't in the bundle; method→key scopes
// are re-granted per environment via the admin UI/CLI. The legacy ApiKeys
@@ -184,7 +184,7 @@ public sealed class EntitySerializer
ReturnDefinition: m.ReturnDefinition,
TimeoutSeconds: m.TimeoutSeconds)).ToList())
{
// M8 site/instance-scoped payloads. These are init-only (non-positional)
// Site/instance-scoped payloads. These are init-only (non-positional)
// on BundleContentDto, so they're set via object-initializer here.
Sites = aggregate.Sites.Select(s => new SiteDto(
SiteIdentifier: s.SiteIdentifier,
@@ -218,7 +218,7 @@ public sealed class EntitySerializer
FailoverRetryCount: c.FailoverRetryCount,
Secrets: secretValues.Count > 0 ? new SecretsBlock(secretValues) : null);
}).ToList(),
// SMS (S10): mirror the SMTP secret handling exactly — the provider
// SMS: mirror the SMTP secret handling exactly — the provider
// auth token (never a public field) rides inside the SecretsBlock, so a
// "share without secrets" export can drop it as a unit. Omit the secret
// entirely when the token is null/empty so the importer's TryGetValue
@@ -248,7 +248,7 @@ public sealed class EntitySerializer
UniqueName: inst.UniqueName,
TemplateName: templateNameById.TryGetValue(inst.TemplateId, out var tn) ? tn : string.Empty,
SiteIdentifier: siteIdentifierBySiteId.TryGetValue(inst.SiteId, out var isid) ? isid : string.Empty,
// TODO(M8): Areas don't travel in the bundle yet — EntityAggregate
// TODO: Areas don't travel in the bundle yet — EntityAggregate
// carries no Areas collection, so there's no id→name lookup. Emit
// null until area transport is added; the importer leaves AreaId null.
AreaName: null,
@@ -268,7 +268,7 @@ public sealed class EntitySerializer
ConditionFilterOverride: o.ConditionFilterOverride)).ToList(),
// Connection bindings reference their DataConnection by NAME — the
// numeric DataConnectionId FK can't survive a cross-environment
// bundle, so the importer (D1) resolves ConnectionName→target FK at
// bundle, so the importer resolves ConnectionName→target FK at
// apply time. If the FK doesn't resolve in this aggregate (orphan
// row), the name comes through empty.
ConnectionBindings: inst.ConnectionBindings.Select(b => new InstanceConnectionBindingDto(
@@ -424,7 +424,7 @@ public sealed class EntitySerializer
var list = new NotificationList(dto.Name) { Id = ix + 1, Type = dto.Type };
foreach (var r in dto.Recipients)
{
// S10: reconstruct whichever contact(s) the bundle carries.
// Reconstruct whichever contact(s) the bundle carries.
// Email-present recipients use the email ctor (unchanged
// historical path) and additively restore the phone number;
// phone-only recipients (null email) take the SMS factory so we
@@ -453,7 +453,7 @@ public sealed class EntitySerializer
})
.ToList();
// SMS (S10): mirror the SMTP path — restore AccountSid/FromNumber and decrypt
// SMS: mirror the SMTP path — restore AccountSid/FromNumber and decrypt
// the auth token back out of the SecretsBlock (null when the key was omitted
// because the source token was null/empty).
var smsConfigurations = content.SmsConfigs
@@ -469,7 +469,7 @@ public sealed class EntitySerializer
})
.ToList();
// Inbound API keys are not transported (re-arch C4) — content.ApiKeys is a
// Inbound API keys are not transported — content.ApiKeys is a
// legacy field only present on pre-C4 bundles; it is ignored here. The
// BundleImporter is responsible for counting and reporting any such keys.
// ApiMethod.ApprovedApiKeyIds is likewise not reconstructed: scopes are
@@ -484,7 +484,7 @@ public sealed class EntitySerializer
})
.ToList();
// M8: sites first — assign synthetic ids by ordinal. Addresses are carried
// Sites first — assign synthetic ids by ordinal. Addresses are carried
// verbatim; the importer decides whether to keep or rewrite them.
var sites = content.Sites
.Select((dto, ix) => new Site(dto.Name, dto.SiteIdentifier)
@@ -516,7 +516,7 @@ public sealed class EntitySerializer
// Instances: resolve template + site by name/identifier. Areas don't travel
// (see export TODO), so AreaId stays null. Connection bindings keep their
// resolved DataConnection NAME on the wire DTO — the importer (D1) reads
// resolved DataConnection NAME on the wire DTO — the importer reads
// bindings from BundleContentDto.Instances[].ConnectionBindings directly and
// resolves ConnectionName→the target environment's DataConnectionId at apply
// time. FromBundleContent is NOT on the importer's path (BundleImporter walks
@@ -13,7 +13,7 @@ public sealed class ManifestBuilder
{
public const int CurrentBundleFormatVersion = 1;
// Schema minor version. Bumped 1.0 → 1.1 in M8 to carry the additive
// Schema minor version. Bumped 1.0 → 1.1 to carry the additive
// site/dataConnection/instance summary counts (and their content arrays).
// Minor bumps are additive-only: ManifestValidator gates solely on
// BundleFormatVersion, so an older "1.0" bundle still validates Ok.