From 3ed05f05957bb96d174655d77a54f29cbc7fe352 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 05:38:58 -0400 Subject: [PATCH] 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-script-scope-access-design.md | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/plans/2026-05-12-script-scope-access-design.md diff --git a/docs/plans/2026-05-12-script-scope-access-design.md b/docs/plans/2026-05-12-script-scope-access-design.md new file mode 100644 index 0000000..9ff6e3e --- /dev/null +++ b/docs/plans/2026-05-12-script-scope-access-design.md @@ -0,0 +1,184 @@ +# 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.): + +```csharp +Attributes["X"] // read; throws if missing +Attributes["X"] = value // write +Attributes.TryGet("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: + +```csharp +public record ScriptScope( + string SelfPath, // "" for root, "TempSensor" for composed + string? ParentPath, // null if SelfPath == "" + IReadOnlyList 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: on `. +- Hover `Children["X"]` → `composition X: `. +- 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 `ResolvedScript` → `FlattenedScript` → 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.