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.
This commit is contained in:
184
docs/plans/2026-05-12-script-scope-access-design.md
Normal file
184
docs/plans/2026-05-12-script-scope-access-design.md
Normal file
@@ -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<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:
|
||||
|
||||
```csharp
|
||||
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 `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.
|
||||
Reference in New Issue
Block a user