Script file access (design docs/plans/2026-09-28-script-file-access-design.md): Files.ReadTextAsync /
ReadLinesAsync / ExistsAsync on site ScriptGlobals (+ Instance.Files), backed by the site-singleton
ScriptFileReader. The one host-mediated exception to the System.IO ban - scripts still never name a
System.IO type. OFF by default: ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots (empty = every call
throws Disabled) + MaxFileBytes (1 MiB, validated 1..64 MiB, roots must be absolute). Guard: fully
qualified, no device paths / ADS, canonical path strictly inside a root (directory-boundary match), no
reparse point on the file or any directory below the root, size cap before and during the read.
Errors are one Commons type, ScriptFileAccessException(+ScriptFileAccessFailure); Commons.Types.Scripts
joins the default script imports on every surface so it binds by short name. Mirrored on
ScriptCompileSurface (CompileFiles, parity-pinned) and the Test Run sandbox (binds, refuses Disabled).
Inbound (central) scripts do not get it.
Rig: docker/recipes (sample RecipeValueName,RecipeValue files incl. an invalid one) bind-mounted
read-only at /app/recipes on both site-a nodes with AllowedRoots [/app/recipes]. The TestMesMachine
SimDelmiaRecipe simulator now reads + parses the downloaded file, converts each value to its recipe tag's
type and loads it all-or-nothing (14 new TestDelmiaReceiver attributes); verify-mes-test-machine.sh
group A covers accept / re-download / invalid lines / NotFound / OutsideAllowedRoots, VERIFY_GROUPS
selects groups (rig: AD).
OnParametersSetAsync awaits the principal. On a real cookie session that await
suspends, so the FIRST render ran with _visible still false and
OnAfterRenderAsync(firstRender: true) skipped EnsurePollingStarted(); the
render that followed the resolve was firstRender: false, and nothing else
started the poll unless AuthenticationStateChanged happened to fire. The block
therefore rendered and sat on 'Checking for updates…' for the life of the
circuit. OnParametersSetAsync now starts polling itself when visible (the
start is idempotent and the OnAfterRender call is kept).
Also corrects the comments that called the non-forced
GetAuthenticationStateAsync cheap: on CookieAuthenticationStateProvider both
entry points revalidate against the database and differ only in failClosed, so
the saving is not asking the provider on a tick and reusing the cascaded state
the router already resolved. The provider fallback is a real refresh, is only
taken when no cascade is present (AddCascadingAuthenticationState is
registered, so it is dead in production), and is now resolved at most once per
auth state rather than on every parameter set.
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
The gate could pass vacuously. Every site-list read went through a bare
`ROWS=$(site_rows)` with neither the exit status nor the row count checked, and
an empty result satisfied every downstream assertion for free: no rows means no
malformed hash for B1/B2 to report, and a diff of two empty strings is no
difference at all for C1/D2. A down management API would therefore have read as
a clean gate.
Reads now go through read_rows, which fails loudly on a non-zero CLI exit
(site_rows exits 2 on an empty list, and pipefail surfaces a failed call) and
aborts unless the line count equals the site count established in step A —
keeping the running tally so the abort still prints PASS/FAIL. Verified against
an unreachable URL: FAIL 1, exit 1.
The join(1)/cut/diff comparisons assumed lexicographic id order matches numeric
order, which breaks once a site id reaches 10 alongside single-digit ids (and BSD
join silently drops the unmatched rows rather than complaining). Replaced with a
python3 comparison keyed by integer id that also asserts the id SETS match, so a
site appearing or vanishing between two reads invalidates the comparison instead
of quietly shrinking it. Same for the summary table.
Also: set -euo pipefail like every sibling script (the cmd && pass || fail guards
keep -e safe, and -e does not cover var=$(cmd) — hence the explicit checks
above); the cleanup trap is armed BEFORE the shared-script create and falls back
to finding the probe by its unique name, so a create that succeeds server-side
but whose id we fail to parse can no longer orphan a probe; mktemp instead of
/tmp/pu-deploy.$$.
PendingUpdatesTests: the rail-scroll check asserted scrollHeight >= clientHeight,
which holds for every element. It now asserts what the pane is actually for —
the pane is bounded by the viewport and the rail footer holding the indicator
stays on screen, so a nav that grows the rail instead of scrolling fails. Live
values are genuinely overflowing (scrollHeight 1196 vs clientHeight 680, footer
bottom 765 of 900), so the assertion has real content.
Re-run against the rig: PASS 13 / FAIL 0; Playwright 3/3.
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
SiteScopeService's instance methods resolve the AUTHORITATIVE state — a
fail-closed, DB-backed group-to-role re-mapping. The indicator called them on
every 30s tick, and it lives in MainLayout, so that was one or two forced
session revalidations per open circuit per page, forever: exactly the
per-circuit cost IPendingUpdatesCache exists to avoid.
Extracts the pure rule as SiteScopeService.Resolve(ClaimsPrincipal) (no I/O;
ResolveAsync now delegates to it, no behaviour change for existing callers).
The indicator takes the cascaded AuthenticationState — falling back to a
NON-forced GetAuthenticationStateAsync, as Templates/FlowDebugger do — and
computes roles plus scope once per auth state, re-running on
AuthenticationStateChanged so a mid-session revocation hides the block
instead of leaving it stale for the life of the circuit. Also routes the
first load through PollGate, adds aria-hidden to the icons, and comments the
off-renderer field writes and the InvalidOperationException catch.
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
Rig rebuilt from this branch (image built at d8979664; migration
20260914173147_AddSiteArtifactRevision applied by central-b on boot).
verify-pending-updates.sh drives the CLI end to end: the site list carries the
artifact-revision fields, a fleet-wide deploy stamps every site with a
sha256:<64 hex> hash and a fresh timestamp, creating a shared script leaves the
stamp alone until the next deploy and then moves EVERY site's hash, and deleting
it again returns every hash byte-for-byte to its pre-create value — so the hash
is content-addressed, not deploy-addressed. PASS 13 / FAIL 0.
Step A asserts the SHAPE rather than key presence: the management API serializer
omits null properties, so on a never-deployed site the two keys are absent, not
present-and-null. Step E is a soft reachability check only; the badge text and
the rail indicator land after the Blazor circuit's post-render load.
PendingUpdatesTests covers that browser half — the rail footer settles off
"Checking for updates…", the Sites page carries an Artifacts badge per site card
plus a working Re-check control, and the rail nav's scroll pane computes
overflow-y: auto at lg+. 3/3 passed against the rig (rail read "Up to date",
3 badges over 3 cards, all "Artifacts current").
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
The forced refresh after a successful Deploy Artifacts ran inside the try, so
the Bulk-actions toggle and every per-site Deploy item stayed disabled for a
whole fleet evaluation after the success toast; it now runs after the finally,
behind a re-render, with the Re-check spinner showing it in flight. Also:
pluralise the instance line's noun, drop a stale "as of" caption when a
re-check faults, skip the fleet evaluation when the site load itself failed,
and render the badge tooltip through UtcDateTime so the UTC label is honest.
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
Adds PendingUpdatesIndicator to MainLayout's RailFooter, above .rail-user.
Self-gating on the Deployer/Administrator role claims, it polls the
process-wide single-flight IPendingUpdatesCache every 30s through PollGate
and renders four states: checking / up to date / pending (warning pill plus
an 'N instances · M sites' breakdown) / unavailable. Each half of the
breakdown links only for the role that can act on it (Topology => Deployer,
Sites => Administrator), and a site-scoped operator's totals are narrowed
through SiteScopeService so they never count a site they cannot see.
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
Each site card carries an Artifacts badge (current / out of date / never
deployed) plus an "N of M instances out of date" line linking to Topology,
both read from the shared pending-updates snapshot. The snapshot loads after
the site list so a slow fleet evaluation never holds the cards, and a failure
degrades the badge to "unavailable" instead of taking the page down. Header
gains a Re-check button and an as-of caption; a successful Deploy Artifacts
forces a refresh so the badge flips immediately.
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
Both repositories are scoped over one DbContext, so a failed stamp save left the
Site entries tracked as Modified and the deployment record's (uncaught)
SaveChangesAsync rethrew it — losing the audit row and failing a deploy in which
every site succeeded. Ordering rule now: audit row first, stamp after, inside its
own try/catch. Same in RetryForSiteAsync, where the site load is caught too so
nothing on the stamp path can reach the method's catch.
Also: the method-definitions canonicaliser matches the name property
case-insensitively (a naming-policy change at the producer would otherwise
silently turn the sort into a no-op), and ComputeCurrentArtifactHashesAsync
documents that a per-site query failure aborts the whole batch by design.
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
One scoped read that answers "is anything waiting to be deployed?" for the whole
fleet: each site's stored artifact revision hash compared against what a deploy
would send right now (NeverDeployed / Current / OutOfDate), plus per-site
deployed/stale/unknown instance tallies from the batched staleness evaluation.
NeverDeployed is deliberately NOT a pending update — the operator has not deployed
yet — and exceptions propagate so the caller's single-flight cache can render
"unavailable" rather than invent an "up to date". Adds the validated
PendingUpdatesCacheTtl (60 s) that TTL will key off.
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
ArtifactRevisionHashService (deterministic SHA-256 of the per-site
DeployArtifactsCommand, DeploymentId/timestamp excluded) stamps
Site.ArtifactRevisionHash/ArtifactsDeployedAt for every site whose deploy
succeeded; failed sites keep their previous stamp.
ComputeCurrentArtifactHashesAsync answers "what would it be now".
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
DeploymentService.GetInstanceStalenessAsync shares a FlattenSession across
the batch, reads only the (InstanceId, RevisionHash) projection and pays the
snapshot read + I-1 List normalisation only for a mismatch, so the fleet
aggregate agrees exactly with the per-instance Topology badge. The I-1 logic
is extracted from GetDeploymentComparisonAsync unchanged.
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
Site.ArtifactRevisionHash / ArtifactsDeployedAt (nullable, migration
AddSiteArtifactRevision) so the Sites page can tell whether a site's
system-wide artifacts are current. Not transported. Adds the
GetDeployedRevisionHashesAsync projection for the fleet-wide staleness batch.
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
A null resetValue encoded to null, which wrote an empty string and then handed the
waiter a null target (= any change), fast-pathing on the stale cache and reinstating
the race the overload exists to close. The context parameter is now non-nullable and
the accessor throws ArgumentException before any write. Also: additive-timeout note on
all surfaces, batch-failure message no longer names the bool overload, and the
error-message/batch-failure branches are pinned.
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
The Run-inbound-API-method pane rendered one text box per top-level schema
field and coerced the text on the page; an array/object field went through
JsonNode.Parse and, on failure, was SENT AS A STRING, while an empty box was
silently dropped. A required array-of-object parameter (MesMoveOut WorkOrders)
could not be entered at all — QA runs reached the site with "WorkOrders":[].
- The method pane now renders the same shared ParameterValueForm the script
pane uses (typed inputs, nested groups, add/remove list item editors,
$ref:lib: resolution, inline validation), keyed on the method id so a change
of method remounts it. The page keeps ONE value tree and BuildParameters()
serializes it as-is; the flat _inputs / Coerce() / SetInput path is deleted.
- Form | Raw JSON toggle: raw mode is a Monaco json editor for the whole
request body, given the method's root schema for completion, parsed and
schema-validated (InboundApiSchema.Validate) on every edit with the error
inline. Entering raw mode seeds the editor from the tree; leaving it
re-mounts the form over the last valid tree.
- Run is disabled while the form or the raw document is invalid (a required
field left empty disables Run before the first keystroke).
- localStorage per-method entry stores the value tree; on restore it is
validated against the schema and dropped if invalid, which also retires the
pre-#37 flat string-per-field entries.
- ParameterValueForm.ToJsonSchema / JsonElementToObject made internal static
for page reuse.
- Tests: 7 new bUnit tests (WorkOrders array-of-object round-trip to the
RunMethodAsync payload, required list-item gating, invalid raw JSON blocks
Run + shows parse/schema/non-object errors, valid raw JSON sent verbatim and
survives the switch back to the form, method switch resets, persisted tree
restored, legacy flat entry dropped); existing method-pane tests moved to
the form's #param-<field> ids and fill the required field before Run.
Playwright smoke selector moved to #param-Text.
- Docs: design doc, Component-CentralUI.md entry-points bullet, CLAUDE.md.
CentralUI.Tests 1425/1425 green; Playwright project builds.
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
Second slice of the 2026-09-09 whole-system review remediation
(docs/plans/2026-09-09-arch-review.md §10), on top of the High slice 146a1323.
Runtime/devices: SR-2 alarm on-trigger integration scope; SR-3 correlated NativeAlarmActor
subscribe retry; SR-4 immutable audited DB target; SR-5 ordered InstanceActor stop/flush;
SR-12 rehydration fence; SR-13 S&F gate recheck per delivery; DC-4 no OPC UA security
downgrade; DC-5 alarm snapshot for every new subscriber; ORCH-1 sync-dispose joins the pump.
Management/UI: MG-2 site scope on audit tree + Flow Debugger Replay; MG-3 stale-probe memo
removed; MG-4 server-owned bulk-deploy deadline; UI-5 sandbox runs in a killable child
process (new CentralUI.SandboxWorker project); UI-14 bounded sandbox output/IPC.
Edge/audit: EI-1 relay audit ownership; EI-2 SkipBodyCapture suppresses error text; EI-3/EI-11
error-field redaction + caps; EI-4 single relay deadline, pre-execution-only retry; EI-5
authenticated relay provenance; EI-10 body cap before audit read.
Persistence/contracts: DL-1 global Instance.UniqueName unique index + conflict translation;
DL-2 DataConnection protocol config encrypted at rest (migration
20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration — coordinated
central upgrade, old binaries cannot read protected rows); DL-3 bounded Replay paging;
DL-6 cross-site Overwrite of a deployed instance rejected; XC-4 proto descriptor contract test.
Config/hygiene: XC-1 central listener isolation by local port; XC-2/XC-3 inert SMTP + cron
keys removed from rig settings; CM-2 env2 bootstrap guard enabled; DC-6 deploy site exit code;
DC-7 notifier retries only explicit standby rejection; XC-5 ClusterClient purged from docs.
Gates recorded in the report: build 0/0, 9 397 passed / 0 failed, rig redeployed on all
eight nodes with the migration applied. 46 Low findings remain open.