Files
ScadaBridge/docs/plans/2026-07-08-script-artifact-invalidation-contract.md
T
Joseph Doherty 2c839def33 feat(commons/transport): ScriptArtifactsChanged invalidation contract + post-commit publish from bundle import (consumer lands in plan 06)
New Commons seam: ScriptArtifactsChanged record + ScriptArtifactKinds + the
IScriptArtifactChangeBus pub/sub interface. Host ships InProcessScriptArtifactChangeBus
(lock-free copy-on-write, swallow-and-log per subscriber) registered as a central-role
singleton. BundleImporter gains an optional IScriptArtifactChangeBus? and, AFTER
tx.CommitAsync, publishes one notification per script-bearing kind (ApiMethod/
SharedScript/Template) whose resolution was Overwrite/Rename, using post-resolution
names — fully guarded so a bad subscriber can't fail a committed import. Contract +
plan-06 handoff table documented. No consumer yet (additive; publisher is correct).

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-09 17:27:40 -04:00

4.8 KiB
Raw Blame History

Script-Artifact Invalidation Contract (ScriptArtifactsChanged)

Status: Seam + first producer shipped in PLAN-05 (arch-review 05, Task 14). Consumer + additional producers land in plan 06.

Problem. When a script-bearing artifact's definition changes at runtime — an Inbound API method's script, a shared script, or a template's scripts — any node that has already compiled and cached a handler/delegate for it keeps serving the stale version until a process restart. This is the root of two recorded project-memory gotchas (a DB-direct edit to a broken inbound method keeps returning "Script compilation failed" from _knownBadMethods; a non-compiling update leaves the old delegate serving). A bundle import is a third mutation path with the same hazard.

The fix is a small, explicit contract for "a script artifact changed → invalidate anything compiled from it," decoupled so producers (importer, management edits, failover) and consumers (compiled-handler caches) never reference each other.

The seam (Commons)

// Commons/Messages/Scripting/ScriptArtifactsChanged.cs
public sealed record ScriptArtifactsChanged(
    string ArtifactKind,               // ScriptArtifactKinds.ApiMethod | SharedScript | Template
    IReadOnlyList<string> Names,       // post-resolution (renamed) names
    string Source,                     // "BundleImport" | "Management" | "FailoverActivation"
    DateTimeOffset OccurredAtUtc);

public static class ScriptArtifactKinds
{
    public const string ApiMethod = "ApiMethod";
    public const string SharedScript = "SharedScript";
    public const string Template = "Template";
}

// Commons/Interfaces/Scripting/IScriptArtifactChangeBus.cs
public interface IScriptArtifactChangeBus
{
    void Publish(ScriptArtifactsChanged notification);
    IDisposable Subscribe(Action<ScriptArtifactsChanged> handler);
}

Semantics (normative)

  1. Advisory, at-least-once. A notification is a hint to invalidate, not a command with delivery guarantees. Duplicates are allowed; over-notification (e.g. an Overwrite resolution that actually fell through to Add) is allowed and harmless.
  2. Published only AFTER commit. A producer publishes after its mutating transaction commits — never inside it. A notification for a rolled-back write is worse than a missed one.
  3. Never faults the producer. The bus swallows-and-logs any subscriber-handler exception; the producer additionally guards its own publish call. A misbehaving consumer can never turn a committed write into a reported failure.
  4. In-process, per node. InProcessScriptArtifactChangeBus (Host) is node-local: a lock-free copy-on-write subscriber list. It does not cross the cluster. Cross-node and failover freshness are the consumer's responsibility — every consumer MUST also self-heal without a notification (e.g. a content-hash / revision check on next use), because a notification published on node A does not reach node B, and a node that was down during the change never hears it.
  5. Names are post-resolution. Names carries the artifact's persisted name after import name-resolution (a Rename carries RenameTo).

What plan 05 ships

  • The Commons seam (record + kinds + interface).
  • InProcessScriptArtifactChangeBus in the Host, registered as a central-role singleton.
  • The Transport bundle importer as the first producer: BundleImporter.ApplyAsync collects the Overwrite/Rename resolutions for ApiMethod / SharedScript / Template and publishes one ScriptArtifactsChanged per kind after tx.CommitAsync. The importer's dependency on the bus is optional (IScriptArtifactChangeBus?) so Transport-only hosts (and unit tests) run without it.

Handoff to plan 06

Plan 06 implements the consumer side and the remaining producers:

Item Responsibility
(a) Inbound API consumer InboundScriptExecutor subscribes to the bus and, on an ApiMethod notification, evicts the named entries from _scriptHandlers and _knownBadMethods — closing the "DB-direct edit keeps returning compile error" and "non-compiling update keeps old handler" gotchas.
(b) Management producers Publish ScriptArtifactsChanged from the ManagementActor UpdateApiMethod / shared-script / template-script edit paths (Source = "Management"), so a UI/CLI edit invalidates the same caches a bundle import does.
(c) Cross-node / failover freshness A revision-keyed handler cache (compile keyed by code hash — see PLAN-05 Task 15's ScriptCompileVerdictCache) so a node that missed the in-process notification still self-heals on next use, and a newly-active failover node never serves a stale compile.

Until (a)(c) land, plan 05's publisher is a no-op in practice (no subscribers), but the seam is stable and the producer is correct, so plan 06 is purely additive.