Re-ran all 8 domain reviews at HEAD8c888f13against theb910f5ebbaseline: every round-1 finding source-verified (168 fixed, 0 regressions, 0 false claims); 56 new findings (1 Critical / 4 High / 15 Medium / 36 Low), concentrated in post-baseline code (anti-entropy resync, KPI rollup backfill, live alarm stream) and seams the fixes exposed. Headliners: S&F resync predicate inversion can wipe the delivering node's buffer (02-N1 Critical); resync snapshot exceeds the Akka remoting frame size (02-N2); failover drill kills the one node keep-oldest can't survive (01-N1); unbounded rollup backfill per failover (04-R1); live production API key in untracked test.txt (08-NF1). Adds PLAN-R2-01..08 + .tasks.json manifests and the Round-2 board, P0 list, cross-plan mutexes, and wave order in 00-MASTER-TRACKER.
38 KiB
PLAN-R2-05 — Templates, Deployment & Transport Round-2 Fix Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Close the six NEW findings (N1–N6) in the round-2 report archreview/05-templates-deployment-transport.md (2026-07-12, re-review at HEAD 8c888f13): the Expression-trigger Roslyn-compile leftover on read-only staleness paths (N1 — the sole Medium, the surviving member class of round-1 #12), the first-error-only trigger syntax message (N2 — the trigger twin of the T24 fix), the change-bus publisher skipping Add resolutions (N3), silently-inert locked-member instance overrides on import (N4), the instance-alarm-override gap in the import trust gate (N5), and the unvalidated MaxConcurrentImportSessions knob (N6). All round-1 accepted deferrals (incl. the #15 ReadManifestAsync residue) stay deferred — coverage rows only, no tasks.
Architecture: Two independent lanes. TemplateEngine lane (N1+N2): ValidationService.ValidateExpressionTriggers today runs ScriptTrustValidator.FindViolations + RoslynScriptCompiler.Compile per Expression-triggered script/alarm unconditionally and uncached (ValidationService.cs:131, 531-544) — the fix mirrors round-1 #12 exactly: memoise the verdict in ScriptCompileVerdictCache (whose key must first gain a globals-surface discriminator — it is keyed on code alone at ScriptCompileVerdictCache.cs:53, sound only while ScriptCompiler/ScriptCompileSurface is its sole writer; a TriggerCompileSurface verdict is not interchangeable), then gate the syntax/compile stage behind the same validateScriptCompilation flag that already carries false from GetDeploymentComparisonAsync and StaleInstanceProbe through FlatteningPipeline — the blank-expression check and attribute-reference scan stay unconditional, and the deploy gate keeps the default (true). Transport lane (N3+N4+N5): three surgical BundleImporter changes — publish ScriptArtifactsChanged for non-Skip resolutions including Add (:1798-1803); extend EnumerateTrustGatedScripts (:957-1001, the single enumerator both the apply-time Pass-0 gate at :4309 and the preview gate at :849 share, so one change covers both) to InstanceAlarmOverrideDto.TriggerConfigurationOverride expression bodies; and emit best-effort warnings (ConflictKind.Warning at preview + ImportResult.Warnings at apply) when an instance override targets a locked template member, WITHOUT changing what gets written or how the flattener resolves it. N6 is a one-line RequireThat in TransportOptionsValidator.
Tech Stack: C#/.NET, EF Core (in-memory + MSSQL integration fixtures), Roslyn (Microsoft.CodeAnalysis.CSharp.Scripting), xUnit. Build: dotnet build ZB.MOM.WW.ScadaBridge.slnx. Test per project: dotnet test tests/<project> — targeted filters only per task; no full-suite runs until the plan's terminal verification.
Parallelization
Concurrent lanes (dispatch as separate implementers):
- TemplateEngine lane (serialize — shared
ValidationService.cs+ScriptCompilerTests.cs): T1 → T2 → T3 → T4. - Transport
BundleImporter.csmutex (serialize): T5 → T6 → T7. - Free / file-disjoint: T8 (
TransportOptionsValidator.cs) runs concurrently with everything. - Last: T9 (docs sweep, blockedBy all).
Cross-plan cautions: T5 is the publisher half of N3 only — the subscriber side (Inbound API wiring + the wrong Host comment) is owned by PLAN-R2-06. The publisher fix is independent and self-contained (the plan-06 consumer already self-heals by content comparison, InboundScriptExecutor.cs:370-404), so there is no blockedBy across plans — either plan can land first; the coverage table records the split so both halves land coherently.
Task 1: Full violation/error lists in the trigger-expression syntax check (N2)
Classification: small Estimated implement time: ~3 min Parallelizable with: 5, 6, 7, 8 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs(CheckExpressionSyntax:536, :541) - Test:
tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs(extend — the existingCheckExpressionSyntax_*facts live here, :134-148)
- Write failing tests:
[Fact]
public void CheckExpressionSyntax_MultipleForbiddenApis_ReportsAll()
{
var error = ValidationService.CheckExpressionSyntax(
"System.IO.File.Exists(\"x\") && System.Diagnostics.Process.GetProcesses().Length > 0");
Assert.NotNull(error);
Assert.Contains("System.IO", error); // FAILS today: only violations[0] is surfaced
Assert.Contains("Process", error);
}
[Fact]
public void CheckExpressionSyntax_MultipleCompileErrors_ReportsAll()
{
var error = ValidationService.CheckExpressionSyntax("NoSuchThingA > 1 && NoSuchThingB < 2");
Assert.NotNull(error);
Assert.Contains("NoSuchThingA", error); // FAILS today: only errors[0] is surfaced
Assert.Contains("NoSuchThingB", error);
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter FullyQualifiedName~ScriptCompilerTests→ expect FAIL (second violation/error missing from the message). - Implement — the exact
string.Joinshape Task 24 gaveScriptCompiler.TryCompile(ScriptCompiler.cs:50, 55), applied to the trigger twin atValidationService.cs:534-544:
var violations = ScriptTrustValidator.FindViolations(expression);
if (violations.Count > 0)
return $"uses forbidden API: {string.Join("; ", violations)}";
var errors = RoslynScriptCompiler.Compile(expression, typeof(TriggerCompileSurface));
if (errors.Count > 0)
return $"is not a valid expression: {string.Join("; ", errors)}";
An operator fixing a multi-error expression sees every finding in one deploy round-trip, matching the "Report ALL violations, not just the first" comment ScriptCompiler.cs:46-47 already carries.
4. Run → expect PASS. Re-run the whole filter to confirm the existing CheckExpressionSyntax_* facts still pass.
5. Commit: git add src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs && git commit -m "fix(template-engine): trigger-expression syntax check reports ALL violations/compile errors, not just the first (plan R2-05 T1)"
Task 2: Add a globals-surface discriminator to the verdict-cache key (N1 part 1)
Classification: high-risk (keying of a cache that stores script-trust verdicts — a cross-surface verdict reuse would return a stale "clean" for code never vetted against that surface)
Estimated implement time: ~4 min
Parallelizable with: 5, 6, 7, 8 (blockedBy 1 — shared ScriptCompilerTests.cs)
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompileVerdictCache.cs(GetOrAdd:51-69 gains a leadingstring surfaceparameter; key at :53 becomessurface + ":" + hash) - Modify:
src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompiler.cs(:42 — passnameof(ScriptCompileSurface)) - Test:
tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs(extend)
- Write failing test (compile-red first — the signature changes — then behavioural red):
[Fact]
public void GetOrAdd_SameCodeDifferentSurface_ComputesSeparateVerdicts()
{
ScriptCompileVerdictCache.Clear();
var a = ScriptCompileVerdictCache.GetOrAdd("SurfaceA", "return 1;", () => (true, null));
var hitsAfterA = ScriptCompileVerdictCache.Hits;
var b = ScriptCompileVerdictCache.GetOrAdd("SurfaceB", "return 1;", () => (false, "err"));
Assert.True(a.Ok);
Assert.False(b.Ok); // code-only key would return SurfaceA's verdict
Assert.Equal(hitsAfterA, ScriptCompileVerdictCache.Hits); // no false cross-surface hit
Assert.Equal(2, ScriptCompileVerdictCache.Count);
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter FullyQualifiedName~ScriptCompilerTests→ expect FAIL (compile error on the new parameter — that counts; fix the signature, then the behavioural assertions go red under a code-only key). - Implement:
GetOrAdd(string surface, string code, Func<(bool Ok, string? Error)> factory); key =surface + ":" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code))). Update the class doc-comment: the verdict is a pure function of code + policy + globals surface, so the surface is part of the key — a trigger expression valid againstTriggerCompileSurfaceis NOT interchangeable with aScriptCompileSurfacescript-body verdict (the exact hazard the round-2 report calls out for N1).ScriptCompiler.TryCompilepassesnameof(ScriptCompileSurface)at :42. No behaviour change for script bodies — same single writer, now under an explicit key segment.
- Run the filter → expect PASS (including the pre-existing
TryCompile_SameCodeTwice_SecondCallIsCacheHitcache facts at :157-171 — they exercise one surface and must be unaffected). - Commit:
git add src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompileVerdictCache.cs src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompiler.cs tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs && git commit -m "fix(template-engine): verdict-cache key gains the globals-surface discriminator — cross-surface verdict reuse structurally impossible (plan R2-05 T2)"
Task 3: Cache Expression-trigger verdicts under the trigger surface key (N1 part 2)
Classification: high-risk (trust-gate path — the trigger syntax check is a forbidden-API verdict; a caching bug here weakens the gate) Estimated implement time: ~4 min Parallelizable with: 5, 6, 7, 8 (blockedBy 2 — same files) Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs(CheckExpressionSyntax:531-544 routes throughScriptCompileVerdictCache) - Test:
tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs(extend)
- Write failing test plus the cross-surface lock-in negative:
[Fact]
public void CheckExpressionSyntax_SameExpressionTwice_SecondCallIsCacheHit()
{
ScriptCompileVerdictCache.Clear();
ValidationService.CheckExpressionSyntax("Attributes[\"Temp\"] != null");
var hitsBefore = ScriptCompileVerdictCache.Hits;
var error = ValidationService.CheckExpressionSyntax("Attributes[\"Temp\"] != null");
Assert.Null(error);
Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits); // FAILS today: no cache use
}
[Fact]
public void CheckExpressionSyntax_ScriptSurfaceVerdict_NotReusedForTriggerSurface()
{
// "Notify != null" resolves on ScriptCompileSurface (Notify is a script global,
// ScriptCompileSurface.cs:53) but NOT on TriggerCompileSurface — a shared
// code-only cache entry would wrongly report the trigger expression clean.
ScriptCompileVerdictCache.Clear();
Assert.True(new ScriptCompiler().TryCompile("Notify != null", "S").IsSuccess); // warms the SCRIPT-surface entry
var error = ValidationService.CheckExpressionSyntax("Notify != null");
Assert.NotNull(error); // green today (no cache), and MUST STAY green after T3 — the T2 key makes it structural
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter FullyQualifiedName~ScriptCompilerTests→ expect FAIL (the cache-hit fact; the cross-surface fact is the lock-in guard that must never flip). - Implement — wrap the T1-joined factory in the surface-keyed cache:
internal static string? CheckExpressionSyntax(string expression)
{
// Memoise the verdict under the TRIGGER surface key (see ScriptCompileVerdictCache):
// the verdict is a pure function of expression + policy + TriggerCompileSurface, and
// it is the exact hot-path cost N1 flagged — every staleness sweep / import probe of
// an Expression-triggered config re-ran a full trust compilation + script compile.
var (_, error) = ScriptCompileVerdictCache.GetOrAdd(nameof(TriggerCompileSurface), expression, () =>
{
var violations = ScriptTrustValidator.FindViolations(expression);
if (violations.Count > 0)
return (false, $"uses forbidden API: {string.Join("; ", violations)}");
var errors = RoslynScriptCompiler.Compile(expression, typeof(TriggerCompileSurface));
if (errors.Count > 0)
return (false, $"is not a valid expression: {string.Join("; ", errors)}");
return (true, (string?)null);
});
return error;
}
The stored error is already entity-name-free — CheckExpressionTrigger formats the entity name in at :449-451, matching the cache's name-free contract.
4. Run the filter → expect PASS (both new facts + all pre-existing CheckExpressionSyntax_*/TryCompile_* facts).
5. Commit: git add src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs && git commit -m "perf(template-engine): cache Expression-trigger compile verdicts under the trigger-surface key — unchanged expressions compile once per process (plan R2-05 T3)"
Task 4: Skip the trigger syntax check on read-only staleness/comparison paths (N1 part 3)
Classification: standard (read paths only; the deploy gate keeps the authoritative compile by default — the same shape round-1 #12/T16 shipped for script bodies) Estimated implement time: ~4 min Parallelizable with: 5, 6, 7, 8 (blockedBy 3 — same files) Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs(Validate:131 threads the flag;ValidateExpressionTriggers:370-400 +CheckExpressionTrigger:420-463 gain the gate;validateScriptCompilationdoc-comment :98-105 updated) - Test:
tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ValidationServiceTests.cs(extend)
- Write failing tests (build the config the way the file's existing facts do — a
FlattenedConfigurationwith one script whoseTriggerType = "Expression"andTriggerConfiguration = """{"expression":"this is not C# (("}"""):
[Fact]
public void Validate_SkipCompilation_SkipsExpressionTriggerSyntaxCheck()
{
var config = ConfigWithExpressionTriggerScript("this is not C# ((");
var gated = new ValidationService().Validate(config, validateScriptCompilation: false);
Assert.DoesNotContain(gated.Errors, e => e.Message.Contains("failed validation")); // FAILS today
var full = new ValidationService().Validate(config);
Assert.Contains(full.Errors, e => e.Message.Contains("failed validation")); // deploy gate unchanged
}
[Fact]
public void Validate_SkipCompilation_StillChecksExpressionAttributeReferences()
{
// expression "Attributes[\"Ghost\"] != null" referencing a missing attribute:
// the reference scan is cheap string work and MUST survive the gate.
var config = ConfigWithExpressionTriggerScript("Attributes[\"Ghost\"] != null");
var gated = new ValidationService().Validate(config, validateScriptCompilation: false);
Assert.Contains(gated.Errors, e => e.Message.Contains("Ghost"));
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter FullyQualifiedName~ValidationServiceTests→ expect FAIL (the gated run still emits the syntax error). - Implement:
ValidateExpressionTriggers(FlattenedConfiguration configuration, bool validateExpressionSyntax = true)— additive default parameter, so every existing direct caller/test stays source-compatible.ValidatepassesvalidateScriptCompilationthrough at :131 — the same flagFlatteningPipelinealready threadsfalseinto fromGetDeploymentComparisonAsync(DeploymentService.cs:690) andStaleInstanceProbe(StaleInstanceProbe.cs:37), so no DeploymentManager/Transport change is needed: both read paths stop paying the per-expression trust-compilation + compile the moment this lands.- Thread the flag into
CheckExpressionTrigger; whenfalse, skip ONLY theCheckExpressionSyntaxcall (:446-452) — the blank-expression warning/error (:432-444) and the attribute-reference scan (:454-462) still run, per the report's "the attribute-reference scan can stay". - Extend the
validateScriptCompilationdoc-comment (:98-105): the flag now gates both the script-body compile stage AND the Expression-trigger syntax/compile check; read-only callers skip both, the deploy gate runs both.
- Run the filter, then the full TemplateEngine project (
dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests) → expect PASS, no regressions. - Commit:
git add src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ValidationServiceTests.cs && git commit -m "perf(template-engine): read-only staleness/comparison paths skip Expression-trigger compiles — deploy gate remains the authoritative check (plan R2-05 T4)"
Task 5: Publish ScriptArtifactsChanged for Add resolutions too (N3)
Classification: standard Estimated implement time: ~3 min Parallelizable with: 1, 2, 3, 4, 8 Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs(PublishScriptArtifactChangesfilter :1802 + the doc-comment :1768-1776) - Test:
tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs(extend — theRecordingScriptArtifactChangeBusfixture and the#05-T14publish fact at :1336-1455 are already there)
- Write failing test alongside
ApplyAsync_publishes_ScriptArtifactsChanged_per_kind_after_commit(:1339):
[Fact]
public async Task ApplyAsync_publishes_ScriptArtifactsChanged_for_Add_resolutions()
{
// ApiMethod imported as Add into a target where the name does not exist.
// Delete-then-reimport-as-Add is the N3 edge: a node can still hold a
// _knownBadMethods / compiled-handler entry under that very name, so Adds
// must notify too (over-approximation is safe per the bus contract).
var result = await ApplyBundleWithApiMethodAsync(action: ResolutionAction.Add);
var notification = Assert.Single(_artifactBus.Received,
n => n.ArtifactKind == ScriptArtifactKinds.ApiMethod);
Assert.Contains("DelmiaRecipeDownload", notification.Names); // FAILS today: Adds are filtered out
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~BundleImporterApplyTests→ expect FAIL (no ApiMethod notification —names.Count == 0short-circuits). - Implement — one condition at :1802 plus the comment that justified the old behaviour:
.Where(r => string.Equals(r.EntityType, entityType, StringComparison.Ordinal)
&& r.Action is not ResolutionAction.Skip)
Rewrite the doc-comment (:1768-1776): publishes for every non-Skip resolution — Overwrite, Rename, AND Add. An Add is not "nothing cached yet": an artifact deleted on the target and re-imported as Add under the same name can still have a stale compiled-handler/_knownBadMethods entry on any node. Over-approximation stays safe (the bus is advisory, at-least-once; consumers self-heal), which is the contract's stated design. Keep the Rename → RenameTo name projection at :1803 as-is.
4. Run the filter → expect PASS (including the existing per-kind Overwrite fact and the failed-apply-publishes-nothing fact — the post-commit placement at :1340 is untouched).
5. Commit: git add src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs && git commit -m "fix(transport): script-artifact change publisher covers Add resolutions — delete-then-reimport no longer starves non-self-healing subscribers (plan R2-05 T5)"
Task 6: Trust-gate instance alarm-override trigger expressions (N5)
Classification: high-risk (script trust gate — 5th ScriptTrustValidator call-site completeness; security-adjacent)
Estimated implement time: ~5 min
Parallelizable with: 1, 2, 3, 4, 8 (blockedBy 5 — BundleImporter.cs mutex)
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs(EnumerateTrustGatedScripts:957-1001 gains the instance pass;ExtractTriggerExpression:1009-1033 refactored to expose a JSON-onlyExtractExpressionBody) - Test:
tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/SemanticValidatorImportTests.cs(extend — sits beside the template twinApply_AlarmExpressionTriggerWithForbiddenApi_HardBlocksat :371)
Severity decision (recorded here, per the review convention): instance-override trigger findings are hard errors, matching every other trust-gate surface. The advisory-vs-hard severity split is a property of the name-resolution heuristic only (false-positive-prone, so template findings are warnings — :819, :842-846); the trust gate's semantic verdict is authoritative with no false-positive channel, and Task 20 fixed its severity as "a HARD error for all kinds" (:4301-4308) — template script bodies and template trigger expressions already hard-block today. An instance override is the same executable surface (it replaces the template's trigger configuration in the flattened config and compiles/executes at the site), so it takes the same severity. The deploy gate (ValidateExpressionTriggers over the flattened, post-override config) stays the authoritative backstop — this closes the import-review completeness gap so the operator learns in the wizard, not at deploy time.
- Write failing tests (negative tests required for a trust-gate change):
[Fact]
public async Task Apply_InstanceAlarmOverrideExpressionWithForbiddenApi_HardBlocks()
{
// Bundle: template with an Expression-triggered alarm + an instance whose
// InstanceAlarmOverrideDto.TriggerConfigurationOverride carries
// {"expression":"System.Diagnostics.Process.GetProcesses().Length > 0"}.
var ex = await Assert.ThrowsAsync<SemanticValidationException>(
() => ApplyBundleAsync(bundle)); // FAILS today: imports clean
Assert.Contains(ex.Errors, e => e.Contains("Process") && e.Contains("trigger override"));
// Rollback contract: nothing persisted (mirror the template-twin's assertions).
}
[Fact]
public async Task Preview_InstanceAlarmOverrideExpressionWithForbiddenApi_SurfacesBlocker()
{
var preview = await PreviewBundleAsync(bundle);
Assert.Contains(preview.Blockers, b =>
b.Kind == ConflictKind.Blocker && b.EntityType == "Instance"
&& b.BlockerReason!.Contains("trust violation")); // FAILS today
}
[Fact]
public async Task Apply_InstanceAlarmOverrideExpression_Clean_Imports()
{
// {"expression":"Attributes[\"Temp\"] != null"} → import succeeds, override row persisted verbatim.
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~SemanticValidatorImportTests→ expect FAIL (both forbidden-API cases import/preview clean). - Implement:
- Split
ExtractTriggerExpression(:1009-1033): the trigger-type guard stays inExtractTriggerExpression, the JSON{"expression":"…"}parse moves to a newprivate static string? ExtractExpressionBody(string? triggerConfigJson)it delegates to. - Append the instance pass to
EnumerateTrustGatedScriptsafter the ApiMethod loop (:993-1000):
- Split
foreach (var i in content.Instances)
{
if (IsSkipResolution(resolutionMap, "Instance", i.UniqueName)) continue;
foreach (var o in i.AlarmOverrides)
{
// The DTO carries no trigger type (the type lives on the template
// alarm), so gate ANY override config carrying an {"expression":...}
// string body: if the overridden alarm is not Expression-triggered
// the body never executes, but vetting it anyway is fail-safe — the
// structured (HiLo/threshold) configs have no "expression" key, so
// there is no false-positive channel.
var expr = ExtractExpressionBody(o.TriggerConfigurationOverride);
if (!string.IsNullOrEmpty(expr))
{
yield return ("Instance", i.UniqueName,
$"{o.AlarmCanonicalName} (alarm trigger override expression)", expr);
}
}
}
- No gate-side change needed: the apply-time Pass 0 (:4309) and the preview gate (:849) both iterate this enumerator, so one edit covers both, including the fail-closed validator-throw handling each already has. Update the enumerator's doc-comment (:946-956) to name instance alarm-override expressions in the covered-surface list.
- Run the filter, then
dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~BundleImporterPreviewTests(preview parity) → expect PASS. - Commit:
git add src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/SemanticValidatorImportTests.cs && git commit -m "fix(security): import trust gate covers instance alarm-override trigger expressions — 5th call site is surface-complete (plan R2-05 T6)"
Task 7: Warn when import persists overrides on locked template members (N4)
Classification: standard (warning-only UX; no write-path or flattener behaviour change — the flattener already drops locked-member overrides, FlatteningService.cs:319, :337, :815)
Estimated implement time: ~5 min
Parallelizable with: 1, 2, 3, 4, 8 (blockedBy 6 — BundleImporter.cs mutex)
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs(new best-effort lock scan called fromRunSemanticValidationAsync(:4293, appending towarnings) and fromDetectBlockersAsync(:670, emittingConflictKind.Warningrows); generalize the apply-side warning log wording at :1204-1208) - Test:
tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs(extend)
- Write failing tests:
[Fact]
public async Task Import_OverrideOnLockedAttribute_EmitsWarning_ImportStillSucceeds()
{
// Template attribute "SetPoint" IsLocked=true; instance carries an
// InstanceAttributeOverrideDto for "SetPoint".
var result = await ApplyBundleAsync(bundle);
Assert.Contains(result.Warnings, w => w.Contains("SetPoint") && w.Contains("locked")); // FAILS today: silent
// Behaviour unchanged: the row IS still written (bundle fidelity) — the
// flattener is what ignores it; assert the persisted override row exists.
}
[Fact]
public async Task Preview_OverrideOnLockedNativeAlarmSource_ShowsWarningRow()
{
var preview = await PreviewBundleAsync(bundle);
Assert.Contains(preview.Blockers, b =>
b.Kind == ConflictKind.Warning && b.EntityType == "Instance"
&& b.BlockerReason!.Contains("locked")); // FAILS today
}
[Fact]
public async Task Import_OverrideOnUnlockedAttribute_NoLockWarning()
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~SiteInstanceImportTests→ expect FAIL. - Implement one shared best-effort scan,
CollectLockedOverrideWarnings(BundleContentDto content, Dictionary<(string,string), ImportResolution>? resolutionMap, IReadOnlyList<Template> targetTemplates):- For each non-Skip instance, resolve its template by
dto.TemplateName— bundle DTO first (the version about to be written), else the pre-existing target template (GetAllTemplatesAsyncalready Includes the child collections). - Locked-member name sets from that template's rows: attributes/alarms/native sources with
IsLocked == true(bundle DTOs carryIsLocked; derived templates carry inherited placeholder rows, so own + inherited members match by name). CheckAttributeOverrides(→FlatteningService.cs:319drop),AlarmOverrides(→:337), andNativeAlarmSourceOverrides(→:815) — the alarm loop is the same pattern the report's two cited collections use, included for completeness. - Warning text:
Instance '{unique}' {kind} override '{name}' targets a LOCKED template member — the flattener ignores it, so this part of the bundle's instance config will never take effect. - Best-effort, documented: overrides addressing composed path-qualified members (
y1.z.Val) or derived-shadow locks (LockedInDerivedon a base the placeholder row doesn't reflect) may not match a direct row — an unmatched name emits NO warning (never a false positive; theManagementActorand flattener remain the enforcement points,ManagementActor.cs:898, :978). - Wire it twice:
RunSemanticValidationAsyncappends towarnings(they ridevalidationWarnings→ImportResult.Warningsat :1359 — generalize the "advisory template-script reference warning(s)" log wording at :1206-1208 to "advisory import warning(s)");DetectBlockersAsyncmaps each to anImportPreviewItemwithEntityType: "Instance",Kind: ConflictKind.Warning— the Task-19 machinery, so the wizard renders it with the existing Warning badge, no UI change. - Do NOT block the import and do NOT change what
PopulateInstanceChildrenwrites (:3999-4029) — bundle fidelity keeps the row; the flattener stays the behavioural authority (binding scope ruling: no flattener change).
- For each non-Skip instance, resolve its template by
- Run the filter +
--filter FullyQualifiedName~BundleImporterPreviewTests→ expect PASS. - Commit:
git add src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs && git commit -m "fix(transport): warn on import of instance overrides targeting locked template members — inert rows no longer silent (plan R2-05 T7)"
Task 8: Validate MaxConcurrentImportSessions at startup (N6)
Classification: small Estimated implement time: ~2 min Parallelizable with: 1, 2, 3, 4, 5, 6, 7 (file-disjoint with everything) Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptionsValidator.cs(oneRequireThatinValidate:25-66) - Test:
tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/TransportOptionsValidatorTests.cs(extend — mirrorZeroMaxBundleSizeMb_IsRejected:34)
- Write failing tests:
[Fact]
public void ZeroMaxConcurrentImportSessions_IsRejected()
{
var result = Validate(new TransportOptions { MaxConcurrentImportSessions = 0 });
Assert.True(result.Failed);
Assert.Contains("MaxConcurrentImportSessions", result.FailureMessage); // FAILS today: validates clean
}
[Fact]
public void NegativeMaxConcurrentImportSessions_IsRejected()
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests --filter FullyQualifiedName~TransportOptionsValidatorTests→ expect FAIL. - Implement — the knob its own fix wave added (
TransportOptions.cs:37, enforced atBundleSessionStore.cs:78-88where a0/negative cap rejects every import forever with "Too many concurrent import sessions (0)"):
builder.RequireThat(options.MaxConcurrentImportSessions > 0,
$"ScadaBridge:Transport:MaxConcurrentImportSessions must be positive " +
$"(was {options.MaxConcurrentImportSessions}); a zero/negative cap rejects every import session forever.");
- Run the filter → expect PASS (
DefaultOptions_AreValidmust stay green — the default is 8). - Commit:
git add src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptionsValidator.cs tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/TransportOptionsValidatorTests.cs && git commit -m "fix(transport): validate MaxConcurrentImportSessions at startup — a zero cap no longer bricks imports silently (plan R2-05 T8)"
Task 9: Design-doc sync sweep
Classification: small (docs only) Estimated implement time: ~4 min Parallelizable with: none (run last) Files:
- Modify:
docs/requirements/Component-Transport.md,docs/requirements/Component-ScriptAnalysis.md,docs/requirements/Component-TemplateEngine.md,/Users/dohertj2/Desktop/ScadaBridge/CLAUDE.md(component #24 blurb + Transport bullet)
No tests; concrete edits, then a stale-cross-reference sweep per repo convention:
- Component-Transport.md: (a) trust-gate section — the gated-surface list gains instance alarm-override trigger expressions (T6), same hard severity, with the severity-decision sentence (semantic verdict authoritative; the advisory split remains name-heuristic-only); (b) import-warning section — locked-member override warnings (T7): advisory
ConflictKind.Warningat preview +ImportResult.Warningsat apply, best-effort by direct member name, rows still written, flattener remains the behavioural authority; (c) change-bus paragraph — publisher now notifies every non-Skip resolution including Add (T5), and note the subscriber side lives with the Inbound API (PLAN-R2-06 owns the subscriber wiring + the stale Host comment); (d) options table —MaxConcurrentImportSessionsis now startup-validated (T8). Leave theReadManifestAsyncdeferral note (:92) untouched — still deferred. - Component-ScriptAnalysis.md: fifth-call-site (Transport) surface list gains instance alarm-override trigger expressions; note the verdict cache is keyed by (globals surface, code hash) so script-body and trigger-expression verdicts never cross (T2/T3).
- Component-TemplateEngine.md: validation-pipeline section —
validateScriptCompilation: falsenow also skips the Expression-trigger syntax/compile check on read-only staleness/comparison paths (blank + attribute-reference checks still run; deploy gate authoritative, T4); trigger syntax findings report all violations/errors (T1). - CLAUDE.md: component #24 blurb / Transport Key-Design-Decisions bullet — extend the script-trust-gate sentence: Expression-trigger gating covers template scripts, alarms, and instance alarm-override expressions. No
../scadaproj/CLAUDE.mdchange (no wire-relationship/stack change) — verify and note in the commit message. git diffreview, then commit:git add docs/requirements/Component-Transport.md docs/requirements/Component-ScriptAnalysis.md docs/requirements/Component-TemplateEngine.md CLAUDE.md && git commit -m "docs: sync Transport/ScriptAnalysis/TemplateEngine specs + CLAUDE.md with the round-2 fix wave (plan R2-05 T9)"
Dependencies on other plans
- PLAN-R2-06 (Edge Integrations round 2): owns the subscriber side of the change bus (Inbound API wiring + the wrong Host comment). T5's publisher fix is independent — the shipped plan-06 consumer self-heals by content comparison (
InboundScriptExecutor.cs:370-404), so no cross-planblockedByexists in either direction; the two halves land coherently in any order. R2-06 should read T5's updated publisher doc-comment (Adds now publish) when wiring any non-self-healing subscriber. - No other cross-plan edges. N1's gating rides the
validateScriptCompilationflag PLAN-05 T16 already threads from DeploymentManager — no DeploymentManager file is touched.
Execution order
P0 (start immediately, in parallel): Task 1 (TemplateEngine lane opener), Task 5 (BundleImporter lane opener), Task 8 (free).
Lane A (serialize — shared files): 1 → 2 → 3 → 4.
Lane B (serialize — BundleImporter.cs mutex): 5 → 6 → 7.
Last: 9 (docs sweep), then terminal verification: dotnet build ZB.MOM.WW.ScadaBridge.slnx + dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests && dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests && dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests && dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests (DeploymentManager consumes the Validate flag) before declaring the plan done.
Findings Coverage
| Report finding | Severity | Task(s) |
|---|---|---|
| N1 — Expression-trigger validation Roslyn-compiles on read-only staleness paths, uncached; any shared cache must key by globals surface | Medium (perf) | 2 (surface-discriminated cache key), 3 (cache trigger verdicts), 4 (skip on comparison/probe paths via the existing validateScriptCompilation flag) |
N2 — CheckExpressionSyntax surfaces only the first violation / first compile error |
Low | 1 (the T24 string.Join fix mirrored onto the trigger twin) |
N3 — PublishScriptArtifactChanges skips Add resolutions |
Low | 5 (publisher publishes every non-Skip resolution); subscriber side (Inbound API wiring + wrong Host comment) → PLAN-R2-06 — publisher fix is independent, no cross-plan blockedBy |
| N4 — Import persists silently-inert instance overrides on locked members | Low | 7 (ConflictKind.Warning preview rows + ImportResult.Warnings at apply; best-effort direct-member match; rows still written, flattener behaviour unchanged per ruling) |
N5 — Import trust gate misses InstanceAlarmOverrideDto.TriggerConfigurationOverride expression bodies |
Low (sec) | 6 (enumerator covers instance overrides; hard error — trust-gate severity is uniform, the advisory split is name-heuristic-only; deploy gate stays authoritative backstop; negative tests included) |
N6 — TransportOptionsValidator misses MaxConcurrentImportSessions |
Low (conv) | 8 (one RequireThat > 0) |
| Docs drift from the above | — | 9 |
Accepted deferral — #11 OperationLockManager single-node in-memory |
Medium (R1) | Deferred (unchanged) — invariant recorded Component-DeploymentManager.md:94; structural active-node gating owned by plans 01/07 |
Accepted deferral — #15 residue: LoadAsync manifest-peek (ReadManifestAsync) |
Low (perf, R1) | Deferred (unchanged) — doc-acknowledged Component-Transport.md:92; pure optimisation (session cap already shipped, and N6/T8 now validates it) |
| Accepted deferral — #16 import apply is one long EF transaction | Low (perf, R1) | Deferred (unchanged) — dominant cost removed by #12/N1; restructuring the rollback contract stays high-risk/low-gain |
| Accepted deferral — U4: rename call-site rewriting, Transport-012 filter UI | — | Deferred (unchanged) — recorded limitations, Component-Transport.md:354 |
| Accepted deferral — U5: preview→apply optimistic concurrency window | — | Deferred (unchanged) — recorded decision, Component-Transport.md:138; version fields reserved (ArtifactDiff.cs:33-37) |
| Accepted deferral — U6: three hand-maintained compile-surface mirrors | — | Deferred (unchanged) — guard tests exist; source-generator is an improvement, not a defect |