Files
scadalink-design/docs/plans/2026-05-12-script-scope-access-design.md
Joseph Doherty 3ed05f0595 docs(scripts): design for template-script scope access
Self / Children / Parent accessors with sync-indexer + async-method
shape. Flattening pipeline emits ScriptScope per resolved script;
ScriptCompilationService seeds the accessors at execution time with
no new actor messages or lookup paths.

Phased: (1) runtime accessors + Scope on ResolvedScript, (2)
flattening + deploy round-trip, (3) editor metadata fetch for child
+ parent shapes, (4) Monaco completion / hover / diagnostics
(SCADA006 unknown attribute, SCADA007 unknown composition).

Out of scope: per-template Roslyn-generated typed accessors,
locking-aware writes (covered by lock-enforcement pass), and
sibling-of-sibling chained navigation.
2026-05-12 05:38:58 -04:00

8.4 KiB

Script scope access: self / child / parent

Goal

Template scripts get an ergonomic read/write API for:

  • The current template's attributes (Attributes["X"]).
  • Child composition attributes (Children["TempSensor"].Attributes["Temperature"]).
  • Child composition scripts (Children["TempSensor"].CallScript("Sample")).
  • The parent composition (when this template is composed inside another): Parent.Attributes["SpeedRPM"], Parent.CallScript("Trip").

Editor (Monaco) provides completion, hover, and diagnostics on all the above.

What already exists

  • Each Template has Attributes, Compositions (named sub-template references), Scripts, Alarms.
  • Flattening produces ResolvedAttribute.CanonicalName as the path-qualified name: direct attrs are bare, composed attrs are "InstanceName.MemberName".
  • InstanceActor stores _attributes[canonicalName] — flat dict keyed by the fully composed canonical name.
  • ScriptRuntimeContext.GetAttribute(name) does a flat lookup. So GetAttribute("TempSensor.Temperature") already works if the canonical name is in the dict. What's missing is scope-relative access — a script on TempSensor cannot say "my Temperature" without knowing it's composed under some parent path.
  • ScriptRuntimeContext.CallScript(name) Ask-pattern-routes to a Script Actor. Cross-composition / parent routing is not implemented.
  • The actor topology is one Instance Actor per top-level instance — composed sub-templates are flattened into the parent's actor state, not separate actors. This is good news: parent/child access is path arithmetic, not ActorRef hopping.

Runtime API (new)

Three accessors layered on ScriptGlobals (in addition to the existing Instance.*, Parameters, Scripts.CallShared, etc.):

Attributes["X"]                  // read; throws if missing
Attributes["X"] = value          // write
Attributes.TryGet<T>("X", out v) // typed read with fallback
Children["TempSensor"].Attributes["Temperature"]
Children["TempSensor"].CallScript("Sample", new { count = 3 })
Parent.Attributes["SpeedRPM"]    // null check: Parent is null at the root
Parent.CallScript("Trip")

Internally each is a thin wrapper holding a ScopePath (string) plus a reference to ScriptRuntimeContext. The indexer / CallScript prepend the scope path to the key and delegate to the existing Instance.GetAttribute / Instance.SetAttribute / Instance.CallScript. No new actor messages, no new lookup pathway.

Children["X"] returns a new accessor with prefix SelfPath + "." + X. Parent returns an accessor with the parent prefix (null if no parent). Chained child/parent navigation works naturally because each accessor is the same type returning the same type.

Compile-time scope injection

Every compiled script needs to know its own ScopePath. That's captured by the flattening pipeline and passed into ScriptGlobals at execution time:

public record ScriptScope(
    string SelfPath,            // "" for root, "TempSensor" for composed
    string? ParentPath,         // null if SelfPath == ""
    IReadOnlyList<string> ChildInstanceNames);

ResolvedScript gains a Scope: ScriptScope field. The flattening service already walks the composition tree to compute canonical names — extending it to emit the scope per script is mechanical.

ScriptCompilationService.Compile reads the scope and seeds ScriptGlobals. Attributes, Children, Parent before the script runs. No code-generation; the accessors close over the scope path at construction time.

Editor surface

The editor side carries the same metadata that the runtime gets:

  • The current template's attribute set (names + types).
  • Each composition: instance name → resolved child template's attribute set AND script list. The form already loads compositions in TemplateEdit.
  • The parent template's attribute and script lists, ONLY when the open template is composed inside another. We surface this as null otherwise.

New completion contexts:

In code Suggests
Attributes["X"] declared attribute names of current template
Children["X"] composition instance names
Children["X"].Attributes["Y"] attribute names of the resolved child template
Children["X"].CallScript("Y" script names of the resolved child template
Parent.Attributes["X"] parent template's attribute names
Parent.CallScript("X" parent template's script names

New diagnostics:

  • SCADA006: unknown attribute name on the appropriate scope.
  • SCADA007: unknown child composition name in Children["X"].

Existing Instance.GetAttribute("X") / Instance.CallScript("X") keep working unchanged. Editor support for those can fall out of the same metadata if we want it.

Hover + signature help

  • Hover Attributes["X"]attribute X: <Type> on <TemplateName>.
  • Hover Children["X"]composition X: <ChildTemplateName>.
  • Signature help for Children["X"].CallScript(...) reuses the existing shape pipeline once the child template's scripts are reachable as ScriptShape[].

What needs to be passed from the form

TemplateEdit already loads the open template's attributes and scripts. Two new pieces:

  1. Resolved child compositions: for each Composition row, fetch the composed template's Attributes and Scripts. The repository already has GetTemplateByIdAsync — call it for each composition.
  2. Parent template (if any): query the repository for templates that compose this one. If exactly one, pass its shape. If multiple or none, pass null and emit Parent.X accesses as diagnostics-by-the-user (since the parent context is ambiguous in design time — the runtime knows because it's running inside one specific deployment, but the editor doesn't).

Edge case: a template composed into multiple parents has no single parent at edit time. Acceptable behaviour: Parent autocompletion is suppressed; using it still compiles but emits a warning at deploy time. Document this clearly.

Phased rollout

  1. Runtime first. Add Attributes / Children / Parent accessors. Wire scope into ResolvedScript and the flattening pipeline. Test with existing flat templates (Scope.SelfPath = ""). Verify a composed script's Attributes["Temperature"] reads through correctly. Site-runtime tests.

  2. Flattening + deployment. Verify the deployed artifact carries the new Scope field through ResolvedScriptFlattenedScript → site-side. Run a round-trip deploy + execute.

  3. Editor metadata. TemplateEdit fetches child template shapes for each composition and optionally the parent. New Monaco context fields.

  4. Editor completion + diagnostics. New string-literal completion contexts. SCADA006 / SCADA007 diagnostics on the Diagnose path. Hover.

Each phase is a separate commit and independently shippable.

Out of scope

  • Composing the same template multiple times under different names on the same parent (already supported by the data model; the editor just lists each composition).
  • Sibling-of-sibling access (Children["A"].Parent.Children["B"]). The accessor API supports it naturally but we don't actively suggest it.
  • Locking-aware writes (Attributes["X"] = v when X is locked). The attribute lock is enforced at deployment validation, not at script-write time; runtime writes that hit a locked attribute should reject. Out of scope for this design — covered by the existing lock-enforcement pass.
  • A formal type for Children / Parent in the editor's Roslyn analysis (the strict Roslyn route would auto-generate per-template accessor types). We use a dictionary-style indexer for both runtime and editor, with editor awareness coming from the metadata pipeline, not from per-template C# types.

Open questions

  • Should Attributes["X"] throw or return null on unknown key? The existing GetAttribute logs a warning and returns null. Same here for consistency.
  • Async vs sync indexer? Decided: both. Sync Attributes["X"] / Attributes["X"] = v indexer for ergonomics, plus Attributes.GetAsync("X") / Attributes.SetAsync("X", v) for callers that want to be explicit about the actor Ask. The sync path internally blocks on .GetAwaiter().GetResult() — acceptable because all script bodies already run on a dedicated blocking-I/O dispatcher per the project conventions in CLAUDE.md.