Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d14e0ee4b1 | |||
| 9e243493fb | |||
| b6f383a225 | |||
| ba994a59c5 | |||
| 05d0631cdd | |||
| 00d8a923af | |||
| 82f52e81ce | |||
| 3e490f2a7d | |||
| acbb9eafa5 | |||
| 8df15b34b8 | |||
| 2f217f5742 | |||
| 6f8b9c755d | |||
| 0fe1972960 | |||
| 4e633b1e64 | |||
| 8657fae14f | |||
| 08957cc907 | |||
| 2ee5586406 | |||
| 6864890e5f | |||
| abc58e6394 | |||
| abb1581d45 | |||
| 3da84825fc | |||
| d21b7a5a48 | |||
| 9f4d7d4bcb | |||
| b2ea9c6c74 | |||
| e9c412e528 | |||
| e697477c1f | |||
| 4d7f09d550 | |||
| 68f812eaa4 | |||
| aebdd56b52 | |||
| a358244d9e | |||
| 43e87a7492 | |||
| f6c3f7c593 | |||
| 6d38e89be0 | |||
| 9d5cf7100e | |||
| fc784b4137 | |||
| c8e90daafb | |||
| 127ec25425 | |||
| 57f08c213b | |||
| 4df3a55824 | |||
| 7caa8bfd99 | |||
| b8f91bab2d | |||
| 3c9b101dfc | |||
| d0af884760 | |||
| 01bcca992c | |||
| 6dc5d94cb9 | |||
| 819ca4d7ce | |||
| a949fe6f57 | |||
| 0c9dffed78 | |||
| 1c99d6fa8d | |||
| 9ab27d5d61 | |||
| f6822f8f45 | |||
| 410349767d | |||
| 316153dc98 | |||
| a506b19d17 | |||
| fdfd5e1b27 | |||
| 8aa6bf2270 | |||
| 88638d774a | |||
| e0851e3e17 | |||
| a26d6ba317 | |||
| 697be0ce43 | |||
| 4558bc3b1f | |||
| 2d03f2d507 | |||
| 0123b68719 | |||
| d1ade5653b | |||
| 663d03e89c | |||
| 63c16d6912 | |||
| b3dc17a5fe | |||
| 1c9159a7ce | |||
| 5d4853a1f0 | |||
| c1216044a4 | |||
| 248676ed16 | |||
| 47850c0f53 | |||
| bb138d5254 | |||
| 9a12826172 | |||
| 86d129de6c | |||
| dbec3ee263 | |||
| a1abbff760 | |||
| 266f001a2e | |||
| e04c2617cc | |||
| 97afa84fcd | |||
| 0eb44314cb | |||
| a5256e9b12 | |||
| e0f105c3b3 | |||
| 8524a7f746 | |||
| c4dcd9bc02 | |||
| 9f91e84d83 | |||
| 7fd5cb2b56 | |||
| 3a8ddb7087 | |||
| 54da10dc00 | |||
| a54602c14a | |||
| 62cddcfa56 |
@@ -55,3 +55,4 @@ docker-env2/*/data/
|
|||||||
|
|
||||||
# Sister-project deployment artifacts (not part of this solution)
|
# Sister-project deployment artifacts (not part of this solution)
|
||||||
/deploy/
|
/deploy/
|
||||||
|
email_details.txt
|
||||||
|
|||||||
@@ -8,29 +8,12 @@ When a change is requested, the default assumption is: update the design doc *an
|
|||||||
|
|
||||||
### Top-level directories
|
### Top-level directories
|
||||||
|
|
||||||
- `src/` — C#/.NET implementation, one project per component (e.g. `ZB.MOM.WW.ScadaBridge.AuditLog`, `ZB.MOM.WW.ScadaBridge.NotificationOutbox`, `ZB.MOM.WW.ScadaBridge.SiteCallAudit`, `ZB.MOM.WW.ScadaBridge.CentralUI`, `ZB.MOM.WW.ScadaBridge.Host`, …). Solution file: `ZB.MOM.WW.ScadaBridge.slnx`.
|
Layout is self-describing (`ls`, plus each directory's own README). Only the non-obvious parts are recorded here:
|
||||||
- `tests/` — Test projects (unit + integration).
|
|
||||||
- `docs/` — Design documentation: `docs/requirements/` (high-level + per-component specs), `docs/test_infra/` (test infrastructure), `docs/plans/` (design-decision and implementation-plan docs). The spec the code implements.
|
|
||||||
- `docker/` — 8-node cluster topology (2 central + 3 sites), `deploy.sh`, per-node `appsettings.*.json`. See [`docker/README.md`](docker/README.md) for setup, ports, and management commands. Rebuild + redeploy with `bash docker/deploy.sh`.
|
|
||||||
- `docker-env2/` — Minimal second cluster topology (2 central + 1 site × 2 nodes), runs concurrently with `docker/` on host ports 91XX. Built specifically for testing the Transport (#24) feature with two real environments. See [`docker-env2/README.md`](docker-env2/README.md). Rebuild + redeploy with `bash docker-env2/deploy.sh`.
|
|
||||||
- `infra/` — Docker Compose for local test services (MS SQL, OPC UA, SMTP, REST API, Traefik). **LDAP is no longer started here** — dev/test LDAP is the shared GLAuth on `10.100.0.35:3893` (source of truth: `scadaproj/infra/glauth/`).
|
|
||||||
- `deploy/` — Production/on-host deployment artifacts (e.g. `deploy/wonder-app-vd03/`: `appsettings.Central.json`, `appsettings.Site.json`, `install.ps1`/`uninstall.ps1`, `RUNBOOK.md`).
|
|
||||||
- `deployments/` — Deployment topology notes (`docker-cluster.md`, `docker-cluster-env2.md`, `README.md`).
|
|
||||||
- `code-reviews/` — Per-component code-review notes (one folder per component, plus `_template`).
|
|
||||||
- `tools/` — Repo maintenance/utility scripts (e.g. `rename-to-scadabridge.sh`).
|
|
||||||
- `AkkaDotNet/` — Akka.NET reference documentation and best-practices notes.
|
|
||||||
- `deprecated/` — Retired docs/notes kept for reference.
|
|
||||||
- `logs/` — Local runtime log output.
|
|
||||||
- `vendor/` — Vendored third-party assets (currently an empty placeholder).
|
|
||||||
- `.claude/` — Claude Code project config (settings, skills, agents).
|
|
||||||
|
|
||||||
### Key documents
|
- `docker/` — the primary 8-node cluster topology (2 central + 3 sites). Rebuild + redeploy with `bash docker/deploy.sh`.
|
||||||
|
- `docker-env2/` — minimal second cluster (2 central + 1 site × 2 nodes) that runs **concurrently** with `docker/` on host ports 91XX; built for testing Transport (#24) against two real environments. Rebuild with `bash docker-env2/deploy.sh`.
|
||||||
- `README.md` — Master index with component table and architecture diagrams.
|
- `infra/` — Docker Compose for local test services (MS SQL, OPC UA, SMTP, REST API, Traefik). **LDAP is no longer started here** — dev/test LDAP is the shared GLAuth on `10.100.0.35:3893` (source of truth: `scadaproj/infra/glauth/`), and the same applies to `docs/test_infra/test_infra.md`, which still reads as if it were a local container.
|
||||||
- `docs/requirements/HighLevelReqs.md` — Complete high-level requirements covering all functional areas.
|
- `deprecated/` — retired docs/notes kept only for reference; not current.
|
||||||
- `docs/requirements/Component-*.md` — Individual component design documents (one per component) — the spec the code implements.
|
|
||||||
- `docs/test_infra/test_infra.md` — Master test infrastructure doc (OPC UA, MS SQL, SMTP, REST API, Traefik). LDAP is the shared GLAuth on `10.100.0.35:3893` (not a local infra container; see `scadaproj/infra/glauth/`).
|
|
||||||
- `docs/plans/` — Design decision and implementation-plan documents from refinement sessions.
|
|
||||||
|
|
||||||
## Sister Projects
|
## Sister Projects
|
||||||
|
|
||||||
@@ -70,35 +53,11 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
|
|||||||
- Run tests with `dotnet test ZB.MOM.WW.ScadaBridge.slnx`. `ZB.MOM.WW.ScadaBridge.CLI.Tests` is now a member of the slnx (added 2026-07; arch-review 08 §2.4) — the old "silently skipped" gotcha no longer applies, so a solution-level test run exercises the CLI suite too.
|
- Run tests with `dotnet test ZB.MOM.WW.ScadaBridge.slnx`. `ZB.MOM.WW.ScadaBridge.CLI.Tests` is now a member of the slnx (added 2026-07; arch-review 08 §2.4) — the old "silently skipped" gotcha no longer applies, so a solution-level test run exercises the CLI suite too.
|
||||||
- **Propagate cross-repo changes to the umbrella index.** This repo is indexed by the parent workspace `~/Desktop/scadaproj`. When a fact its index records changes here — remote/push status, stack/component summary, cross-project wire relationships (OPC UA → OtOpcUa, gRPC → mxaccessgw), or the solution/namespace shape — update the **ScadaBridge entry in [`../scadaproj/CLAUDE.md`](../scadaproj/CLAUDE.md)** in the same change so the umbrella index never drifts from this repo. (Mirrors the same rule in the peer repos, e.g. `MxAccessGateway`/`HistorianGateway`.)
|
- **Propagate cross-repo changes to the umbrella index.** This repo is indexed by the parent workspace `~/Desktop/scadaproj`. When a fact its index records changes here — remote/push status, stack/component summary, cross-project wire relationships (OPC UA → OtOpcUa, gRPC → mxaccessgw), or the solution/namespace shape — update the **ScadaBridge entry in [`../scadaproj/CLAUDE.md`](../scadaproj/CLAUDE.md)** in the same change so the umbrella index never drifts from this repo. (Mirrors the same rule in the peer repos, e.g. `MxAccessGateway`/`HistorianGateway`.)
|
||||||
|
|
||||||
## Current Component List (27 components)
|
## Component List
|
||||||
|
|
||||||
1. Template Engine — Template modeling, inheritance, composition, validation, flattening, diffs.
|
27 components. The catalog — what each one is and its key design commitments — lives in the
|
||||||
2. Deployment Manager — Central-side deployment pipeline, system-wide artifact deployment, instance lifecycle.
|
`scadabridge-components` skill (`.claude/skills/scadabridge-components/SKILL.md`); the authoritative
|
||||||
3. Site Runtime — Site-side actor hierarchy (Deployment Manager singleton, Instance/Script/Alarm Actors), script compilation, Akka stream.
|
spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carries the component table.
|
||||||
4. Data Connection Layer — Protocol abstraction (OPC UA, custom), subscription management, clean data pipe.
|
|
||||||
5. Central–Site Communication — Akka.NET ClusterClient (command/control) + gRPC server-streaming (real-time data), message patterns, debug streaming.
|
|
||||||
6. Store-and-Forward Engine — Buffering, fixed-interval retry, parking, SQLite persistence, replication.
|
|
||||||
7. External System Gateway — External system definitions, API method invocation, database connections.
|
|
||||||
8. Notification Service — Central-only notification-list and SMTP definitions, per-type delivery adapters (sites no longer deliver notifications).
|
|
||||||
9. Central UI — Web-based management interface, all workflows.
|
|
||||||
10. Security & Auth — LDAP/AD authentication, role-based authorization, site-scoped permissions.
|
|
||||||
11. Health Monitoring — Site health metrics collection and central reporting.
|
|
||||||
12. Site Event Logging — Local operational event logs at sites with central query access.
|
|
||||||
13. Cluster Infrastructure — Akka.NET cluster setup, active/standby failover, singleton support.
|
|
||||||
14. Inbound API — Web API for external systems, API key auth, script-based implementations.
|
|
||||||
15. Host — Single deployable binary, role-based component registration, Akka.NET bootstrap.
|
|
||||||
16. Commons — Shared types, POCO entity classes, repository interfaces, message contracts.
|
|
||||||
17. Configuration Database — EF Core data access layer, repositories, unit-of-work, audit logging (IAuditService), migrations.
|
|
||||||
18. Management Service — Akka.NET actor providing programmatic access to all admin operations, ClusterClientReceptionist registration.
|
|
||||||
19. CLI — Command-line tool using HTTP Management API, System.CommandLine, JSON/table output.
|
|
||||||
20. Traefik Proxy — Reverse proxy/load balancer fronting central cluster, active node routing via `/health/active`, automatic failover.
|
|
||||||
21. Notification Outbox — Central component ingesting store-and-forwarded notifications, `Notifications` audit table, dispatcher loop, retry/parking, delivery KPIs.
|
|
||||||
22. Site Call Audit — Central component auditing site cached calls (`CachedCall`/`CachedWrite`); `SiteCalls` audit table, telemetry ingest, reconciliation, KPIs, central→site Retry/Discard relay; sites remain the source of truth.
|
|
||||||
23. Audit Log — Central append-only AuditLog table spanning every script-trust-boundary action (outbound API sync+cached, outbound DB sync+cached, notifications, inbound API). Site SQLite hot-path + gRPC telemetry + reconciliation; combined telemetry with Site Call Audit; central direct-write for Notification Outbox dispatch + Inbound API; monthly partitioning, 365-day retention.
|
|
||||||
24. Transport — File-based, encrypted bundle export/import via Central UI. Templates (with **all** child collections — attributes/alarms/scripts/compositions/**native-alarm-sources**, field-complete incl. `LockedInDerived` + script cadence/timeout), system artifacts, central-only configuration, plus site/instance-scoped config (`Site`s, site `DataConnection`s, `Instance`s + real `AreaName` by name) reconciled across environments by a `BundleNameMap` name-mapping subsystem. Per-conflict resolution with a per-line Myers diff. Runs the **script trust gate** at import review (5th `ScriptTrustValidator` call site — forbidden-API scripts rejected pre-runtime; covers template/shared/ApiMethod bodies, template script + alarm Expression-trigger bodies, **and instance alarm-override trigger expressions** — all hard-block; template-script name-resolution findings are advisory warnings, ApiMethod findings hard-block). Correlated audit via `BundleImportId`. Never touches site runtime nodes (imported instances land `NotDeployed`).
|
|
||||||
25. Script Analysis — Shared authoritative script-trust analyzer: unified forbidden-API deny-list (`ScriptTrustPolicy`), fused semantic+syntactic validator (`ScriptTrustValidator`), Roslyn compile wrapper (`RoslynScriptCompiler`), and compile-only globals stubs (`ScriptCompileSurface`/`TriggerCompileSurface`); consumed by Template Engine, Site Runtime, Inbound API, and Central UI.
|
|
||||||
26. KPI History — Reusable central KPI-history backbone: tall/EAV `KpiSample` store in central MS SQL, `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, not readiness-gated) sampling DI-registered `IKpiSampleSource`s every minute, bucketed query (`GetRawSeriesAsync` + `KpiSeriesBucketer`) + scoped `KpiHistoryQueryService`, and a reusable custom-SVG `KpiTrendChart`; ships trends for Notification Outbox, Site Call Audit, Audit Log, and Site Health.
|
|
||||||
27. DelmiaNotifier — Standalone external client tool (NOT a cluster component, NOT in the Host): a compact Native-AOT (`win-x64`) console app (`WWNotifier.exe`) that DELMIA Apriso shells out to per recipe download. POSTs to the Inbound API `DelmiaRecipeDownload` method (`X-API-Key`, key from `SCADABRIDGE_API_KEY`), with connect-failure-only failover across a comma-list of base URLs, and reports the legacy `YES`/`NO` + exit-code stdout contract — a drop-in replacement for the legacy `WWNotifier` (see `docs/former-api-specs/dnc/`). Zero-dependency BCL-only, `System.Text.Json` source-gen. Project README: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md`; design: `docs/plans/2026-06-26-delmia-recipe-notifier-design.md`.
|
|
||||||
|
|
||||||
## Key Design Decisions (for context across sessions)
|
## Key Design Decisions (for context across sessions)
|
||||||
|
|
||||||
@@ -120,35 +79,34 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
|
|||||||
- DCL write failures returned synchronously to calling script.
|
- DCL write failures returned synchronously to calling script.
|
||||||
- Tag path resolution retried periodically for devices still booting.
|
- Tag path resolution retried periodically for devices still booting.
|
||||||
- Static attribute writes persisted to local SQLite (survive restart/failover, reset on redeployment).
|
- Static attribute writes persisted to local SQLite (survive restart/failover, reset on redeployment).
|
||||||
- **Consolidated site database (LocalDb Phase 1 + 2, complete 2026-07-20).** **Ten** tables now live in ONE `ZB.MOM.WW.LocalDb`-managed SQLite file — the Phase 1 pair (`OperationTracking`, `site_events`) plus Phase 2's `sf_messages` and the seven site config tables (`deployed_configurations`, `static_attribute_overrides`, `shared_scripts`, `external_systems`, `database_connections`, `data_connection_definitions`, `native_alarm_state`), configured by the **required** `LocalDb:Path` (`/app/data/site-localdb.db` on the rig; validated with `ValidateOnStart`, so a site config missing it fails to boot). Both are `RegisterReplicated` tables. Consequences worth knowing:
|
- **Consolidated site database (LocalDb Phase 1 + 2, complete 2026-07-20).** Ten tables now live in ONE `ZB.MOM.WW.LocalDb`-managed SQLite file (enumerate them with `grep -r RegisterReplicated src/`), configured by the **required** `LocalDb:Path` (`/app/data/site-localdb.db` on the rig; validated with `ValidateOnStart`, so a site config missing it fails to boot). Consequences worth knowing:
|
||||||
- `site_events.id` changed from autoincrement INTEGER to an application-minted **GUID**. Last-writer-wins keys on the primary key, so two nodes independently minting `id=1,2,3…` would destroy each other's events rather than merge them. The event-log read path uses a composite `(timestamp, id)` keyset cursor with an **opaque string** continuation token; `EventLogEntry.Id` and both `ContinuationToken`s are `string`/`string?` on the site↔central Akka DTOs.
|
- `site_events.id` changed from autoincrement INTEGER to an application-minted **GUID**. Last-writer-wins keys on the primary key, so two nodes independently minting `id=1,2,3…` would destroy each other's events rather than merge them. The event-log read path uses a composite `(timestamp, id)` keyset cursor with an **opaque string** continuation token; `EventLogEntry.Id` and both `ContinuationToken`s are `string`/`string?` on the site↔central Akka DTOs.
|
||||||
- `ScadaBridge:OperationTracking:ConnectionString`, `ScadaBridge:SiteEventLog:DatabasePath` and — as of Phase 2 — `ScadaBridge:StoreAndForward:SqliteDbPath` + `ScadaBridge:Database:SiteDbPath` are all **migration-only** — nothing reads them but `SiteLocalDbLegacyMigrator`, which copies a pre-Phase-1 file in once (deterministic `mig-{NodeName}-{legacyId}` event ids, `INSERT OR IGNORE`, runs AFTER `RegisterReplicated` so migrated rows replicate) and renames it `.migrated`. Delete the keys once a node has migrated.
|
- `ScadaBridge:OperationTracking:ConnectionString`, `ScadaBridge:SiteEventLog:DatabasePath` and — as of Phase 2 — `ScadaBridge:StoreAndForward:SqliteDbPath` + `ScadaBridge:Database:SiteDbPath` are all **migration-only** — nothing reads them but `SiteLocalDbLegacyMigrator`, which copies a pre-Phase-1 file in once (deterministic `mig-{NodeName}-{legacyId}` event ids, `INSERT OR IGNORE`, runs AFTER `RegisterReplicated` so migrated rows replicate) and renames it `.migrated`. Delete the keys once a node has migrated.
|
||||||
- This incidentally fixes a data-loss bug: both legacy databases defaulted to CWD-relative paths **outside** the mounted volume and were discarded on every container recreate.
|
- This incidentally fixes a data-loss bug: both legacy databases defaulted to CWD-relative paths **outside** the mounted volume and were discarded on every container recreate.
|
||||||
- **Replication is default-OFF and opt-in** via `LocalDb:Replication:PeerAddress` + a matching `ApiKey` on both nodes. `LocalDbSyncAuthInterceptor` is **fail-closed**: no configured key means no sync stream is accepted at all, so a key typo does not degrade to unauthenticated replication — the pair simply stops converging. The sync endpoint shares the existing site gRPC h2c listener (8083); no new port. Rig posture: **site-a replicated, site-b/site-c deliberately not**, so both states are proven side-by-side. Status surfaces on the site health report as `LocalDbReplicationConnected` / `LocalDbOplogBacklog` (both nullable — null means "no data", NOT "disconnected with an empty backlog") and as `localdb_*` Prometheus series. Note `ZbTelemetryOptions.Meters` is an **allowlist** (`SiteServiceRegistration.ObservedMeters`); an unlisted meter exports nothing, silently.
|
- **Replication is default-OFF and opt-in** via `LocalDb:Replication:PeerAddress` + a matching `ApiKey` on both nodes. `LocalDbSyncAuthInterceptor` is **fail-closed**: no configured key means no sync stream is accepted at all, so a key typo does not degrade to unauthenticated replication — the pair simply stops converging. The sync endpoint shares the existing site gRPC h2c listener (8083); no new port. Rig posture: **site-a replicated, site-b/site-c deliberately not**, so both states are proven side-by-side. Status surfaces on the site health report as `LocalDbReplicationConnected` / `LocalDbOplogBacklog` (both nullable — null means "no data", NOT "disconnected with an empty backlog") and as `localdb_*` Prometheus series. Note `ZbTelemetryOptions.Meters` is an **allowlist** (`SiteServiceRegistration.ObservedMeters`); an unlisted meter exports nothing, silently.
|
||||||
- Design: **scadaproj** `docs/plans/2026-07-19-scadabridge-localdb-design.md` (that doc lives in the umbrella repo, not here); Phase 1 plan + Phase 2 gate are here under `docs/plans/`. **Phase 2 is COMPLETE** (branch `feat/localdb-phase2`, live gate PASS 2026-07-20 — all 10 checks, evidence in `docs/plans/2026-07-19-localdb-phase2-live-gate.md`). It moved the config tables + `sf_messages` in and **deleted** `SiteReplicationActor`, its `ReplicationMessages`, StoreAndForward's `ReplicationService`, and `StoreAndForwardStorage.ReplaceAllAsync`. What those did, and why nothing replaced them:
|
- Design: **scadaproj** `docs/plans/2026-07-19-scadabridge-localdb-design.md` (that doc lives in the umbrella repo, not here); Phase 1 plan + Phase 2 gate are here under `docs/plans/`. Phase 2 **deleted** `SiteReplicationActor`, its `ReplicationMessages`, StoreAndForward's `ReplicationService`, and `StoreAndForwardStorage.ReplaceAllAsync` — do not reintroduce them:
|
||||||
|
|
||||||
- `SiteReplicationActor` pushed config deploys to the peer and ran a **notify-and-fetch** exchange (tell the standby a deploy happened; it HTTP-fetches the config itself, with retries and a superseded-404 path). Config rows now simply replicate — **the standby makes no fetch at all** during a deploy (verified live). `SiteReconciliationActor` still fetches at node STARTUP when central reports gaps; that path survives and is a different thing.
|
- CDC replication does all three jobs now: config deploys reach the standby as ordinary row changes — **the standby makes no fetch at all** during a deploy (`SiteReconciliationActor`'s node-STARTUP fetch when central reports gaps is a different, surviving path) — and buffer mutations replicate via triggers on `sf_messages`. `ReplaceAllAsync` was a destructive delete-all-then-insert-all resync and is **unsafe to reintroduce**: a mass DELETE on a replicated table would be captured and shipped to the peer. LocalDb's snapshot resync merges per row under LWW and never deletes, which is also why the old N1 directional-authority guard is gone — there is no wipe left to gate.
|
||||||
- `ReplicationService` fanned each buffer mutation (add/remove/park/requeue) to the standby by hand. CDC triggers on `sf_messages` do it now.
|
|
||||||
- `ReplaceAllAsync` was a destructive delete-all-then-insert-all resync. It was not merely unused after the cutover but **unsafe to keep**: a mass DELETE on a now-replicated table would be captured and shipped to the peer. LocalDb's snapshot resync merges per row under LWW and never deletes, which is also why the old N1 directional-authority guard is gone — there is no wipe left to gate.
|
|
||||||
- **`notification_lists` and `smtp_configurations` are created but deliberately NOT registered.** They are permanently empty on a site (no writer since 2026-07-10, the migrator skips them, the active-node purge keeps them empty), and registering them would open a standing replication channel whose only historical payload was plaintext SMTP passwords. Pinned by a security-named test, and verified live: those two tables have **no CDC triggers** on either rig node.
|
- **`notification_lists` and `smtp_configurations` are created but deliberately NOT registered.** They are permanently empty on a site (no writer since 2026-07-10, the migrator skips them, the active-node purge keeps them empty), and registering them would open a standing replication channel whose only historical payload was plaintext SMTP passwords. Pinned by a security-named test, and verified live: those two tables have **no CDC triggers** on either rig node.
|
||||||
- **Operational constraints (read before upgrading a site pair):** stop and start both nodes TOGETHER — rolling one at a time is no longer supported, since the legacy `SfBufferSnapshot` compatibility handler went with the replicator. And a node offline longer than `LocalDb:Replication:TombstoneRetention` (default 7 days) can resurrect deleted rows on rejoin. See `docs/deployment/topology-guide.md`.
|
- **Operational constraints (read before upgrading a site pair):** stop and start both nodes TOGETHER — rolling one at a time is no longer supported, since the legacy `SfBufferSnapshot` compatibility handler went with the replicator. And a node offline longer than `LocalDb:Replication:TombstoneRetention` (default 7 days) can resurrect deleted rows on rejoin. See `docs/deployment/topology-guide.md`.
|
||||||
- `LocalDb:Replication:MaxBatchSize` batches by ROW COUNT, not bytes, against a 4 MB gRPC cap — the rig pins it to **16** (~70 KB worst-case `config_json` x 16 ~= 1.1 MB). The 500 default would allow ~35 MB.
|
- `LocalDb:Replication:MaxBatchSize` batches by ROW COUNT, not bytes, against a 4 MB gRPC cap — the rig pins it to **16** (~70 KB worst-case `config_json` x 16 ~= 1.1 MB). The 500 default would allow ~35 MB.
|
||||||
- All timestamps are UTC throughout the system.
|
- All timestamps are UTC throughout the system.
|
||||||
- Inter-cluster communication uses **three** transports, not two: **ClusterClient** for command/control (deployments, lifecycle, subscribe/unsubscribe handshake, snapshots); **gRPC** server-streaming for real-time data (attribute values, alarm states); and **plain token-gated HTTP** for the deployment config itself — notify-and-fetch, the site pulls the config from `DeploymentConfigEndpoints` (`ManagementService/DeploymentConfigEndpoints.cs`) with an `X-Deployment-Token` header, `AllowAnonymous` with the per-deployment token as the entire security boundary. Both CentralCommunicationActor and SiteCommunicationActor registered with receptionist (**per node, not as a singleton** — contact rotation reaches whichever node answers). Central creates one ClusterClient per site using NodeA/NodeB as contact points. Sites configure multiple central contact points for failover. Addresses cached in CentralCommunicationActor, refreshed periodically (60s) and on admin changes. Heartbeats serve health monitoring only. **Discovery is asymmetric by design:** central discovers sites from the *database* (`Site.NodeAAddress`/`NodeBAddress`, refreshable at runtime), sites discover central from *appsettings* (`ScadaBridge:Communication:CentralContactPoints`, static — restart required). **Central never buffers for an unreachable site** — the send is dropped with a warning and the caller's Ask times out; a `ConnectionStateChanged` mechanism built for this was deleted as dead code.
|
- Inter-cluster communication uses **three** transports, not two — **all cross-cluster command/control and data now rides gRPC** after the ClusterClient→gRPC migration's Phase 4 (`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`) deleted Akka `ClusterClient`/`ClusterClientReceptionist`: (1) **gRPC command/control** — site→central over the central-hosted `CentralControlService` (`GrpcCentralTransport`, sticky central-a→central-b channel pair; deployments/notifications/health/heartbeat/audit-ingest/reconcile), and central→site over the site-hosted `SiteCommandService` (`GrpcSiteTransport`, per-site NodeA→NodeB channel pair; the 28 lifecycle/OPC-UA/query/parked/route/failover commands); (2) **gRPC** server-streaming for real-time data (attribute values, alarm states, `SiteStreamService`); and (3) **plain token-gated HTTP** for the deployment config itself — notify-and-fetch, the site pulls the config from `DeploymentConfigEndpoints` (`ManagementService/DeploymentConfigEndpoints.cs`) with an `X-Deployment-Token` header, `AllowAnonymous` with the per-deployment token as the entire security boundary. The gRPC boundary is per-site PSK-authenticated (`ControlPlaneAuthInterceptor`, unchanged). There is **no receptionist registration** — discovery is by dialling configured endpoints; central builds one `SitePairChannelProvider` per site (addresses from `Site.GrpcNodeAAddress`/`GrpcNodeBAddress`, refreshed from the DB every 60s and on admin changes), sites dial `ScadaBridge:Communication:CentralGrpcEndpoints` (both central nodes, h2c on `CentralGrpcPort` 8083, **NOT** via Traefik). **Discovery is asymmetric by design:** central discovers site gRPC addresses from the *database* (refreshable at runtime), sites discover central from *appsettings* (`CentralGrpcEndpoints`, static — restart required; `StartupValidator` requires a Site node to list at least one). `Akka.Cluster.Tools` stays for ClusterSingleton; only the ClusterClient part is gone. **Central never buffers for an unreachable site** — the send fails with the caller's Ask/deadline timing out; a `ConnectionStateChanged` mechanism built for this was deleted as dead code.
|
||||||
- **All clusters share ONE ActorSystem name**, `"scadabridge"` — hardcoded at `AkkaHostedService.cs:191`. Central and each site are separate clusters *only* by seed-node partitioning. This is required, not incidental: Akka.Remote address matching means a ClusterClient could not reach a differently-named system.
|
- **All clusters share ONE ActorSystem name**, `"scadabridge"` — hardcoded in `AkkaHostedService` at the `ActorSystem.Create` call. Central and each site are separate clusters *only* by seed-node partitioning. The constraint originated with ClusterClient (Akka.Remote address matching meant it could not reach a differently-named system); whether it is still load-bearing after the gRPC migration has **not** been re-verified, so treat the name as fixed until someone checks.
|
||||||
- **`ActiveNodeEvaluator.SelfIsOldestUp` is THE single definition of "active node"** (`Communication/ClusterState/ActiveNodeEvaluator.cs`) — the **oldest Up member** in a role scope, and explicitly **never `cluster.State.Leader`**: leadership (lowest address) is an Akka-internal concept that diverges from singleton placement permanently once the original first node restarts and rejoins, and both sides claim it during a partition. The equivalence *oldest-Up == where `ClusterSingletonManager` places singletons* **is** the design. `ClusterActivityEvaluator.SelfIsOldest`, the S&F delivery gate, `/health/active` and the heartbeat `IsActive` stamp all delegate here.
|
- **`ActiveNodeEvaluator.SelfIsOldestUp` is THE single definition of "active node"** (`Communication/ClusterState/ActiveNodeEvaluator.cs`) — the **oldest Up member** in a role scope, and explicitly **never `cluster.State.Leader`**: leadership (lowest address) is an Akka-internal concept that diverges from singleton placement permanently once the original first node restarts and rejoins, and both sides claim it during a partition. The equivalence *oldest-Up == where `ClusterSingletonManager` places singletons* **is** the design. `ClusterActivityEvaluator.SelfIsOldest`, the S&F delivery gate, `/health/active` and the heartbeat `IsActive` stamp all delegate here.
|
||||||
- Site nodes carry **two Akka roles**: the base `Site` plus a site-specific `site-{SiteId}` (`BuildRoles`, `AkkaHostedService.cs:386`). Singletons scope to the **site-specific** role.
|
- Site nodes carry **two Akka roles**: the base `Site` plus a site-specific `site-{SiteId}` (`AkkaHostedService.BuildRoles`). Singletons scope to the **site-specific** role.
|
||||||
- **The gRPC boundary is authenticated (PSK) as of 2026-07-22; Akka remoting still is not, and nothing is encrypted.** Akka remoting sets no `enable-ssl`, no secure cookie, no `trusted-selection-paths` — so the ClusterClient command/control path remains open to anyone who can reach the remoting port, and the boundary still assumes a trusted network. The gRPC listener stays **h2c**, but `SiteStreamService` is no longer open: `ControlPlaneAuthInterceptor` (`Host/ControlPlaneAuthInterceptor.cs`) gates `/sitestream.SiteStreamService/` — including the `PullAuditEvents`/`PullSiteCalls` RPCs that return audit rows — against a **per-site preshared key**, fail-closed, constant-time compared, alongside the separate `LocalDbSyncAuthInterceptor` on `/localdb_sync.v1.LocalDbSync/` with its own separate key. **Site side:** `ScadaBridge:Communication:GrpcPsk`, in production `${secret:SB-GRPC-PSK-<siteId>}`, and **`StartupValidator` refuses to boot a site node without it** (an unset key would leave the node healthy-looking but serving nothing). **Central side:** `SitePskProvider` resolves `SB-GRPC-PSK-{siteId}` from the secrets store at channel-build time (sites are added at runtime, so no boot-time expansion is possible), with `ScadaBridge:Communication:SitePsks:{siteId}` as an override for hosts running without a master key — the docker rig uses the latter. One key per site, never fleet-wide. A bearer token over h2c is readable and replayable on-path; TLS is the follow-on hardening and needs no change to this design. Introduced by Phase 0 of the ClusterClient→gRPC migration (`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`).
|
- **The gRPC boundary is authenticated (PSK) as of 2026-07-22; Akka remoting still is not, and nothing is encrypted.** Akka remoting sets no `enable-ssl`, no secure cookie, no `trusted-selection-paths` — so intra-cluster Akka remoting remains open to anyone who can reach the remoting port, and that boundary still assumes a trusted network. The gRPC listener stays **h2c**, but `SiteStreamService` is no longer open: `ControlPlaneAuthInterceptor` (`Host/ControlPlaneAuthInterceptor.cs`) gates `/sitestream.SiteStreamService/` — including the `PullAuditEvents`/`PullSiteCalls` RPCs that return audit rows — against a **per-site preshared key**, fail-closed, constant-time compared, alongside the separate `LocalDbSyncAuthInterceptor` on `/localdb_sync.v1.LocalDbSync/` with its own separate key. **Site side:** `ScadaBridge:Communication:GrpcPsk`, in production `${secret:SB-GRPC-PSK-<siteId>}`, and **`StartupValidator` refuses to boot a site node without it** (an unset key would leave the node healthy-looking but serving nothing). **Central side:** `SitePskProvider` resolves `SB-GRPC-PSK-{siteId}` from the secrets store at channel-build time (sites are added at runtime, so no boot-time expansion is possible), with `ScadaBridge:Communication:SitePsks:{siteId}` as an override for hosts running without a master key — the docker rig uses the latter. One key per site, never fleet-wide. A bearer token over h2c is readable and replayable on-path; TLS is the follow-on hardening and needs no change to this design. Introduced by Phase 0 of the ClusterClient→gRPC migration (`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`).
|
||||||
- **Akka frame size is the default 128 KB with `log-frame-size-exceeding` off**, and no custom serializer is configured — so payload carried over ClusterClient is JSON-escaped a second time by the default Newtonsoft serializer, roughly doubling it. Over the limit the transport drops **that one message** without tearing down the association (heartbeats keep flowing, the site still reports healthy) and the central Ask simply times out. See `docs/known-issues/2026-06-26-deploy-config-exceeds-akka-frame-size.md`; `DeployArtifactsCommand` still carries payload and remains exposed.
|
- gRPC streaming channel — **note the direction is inverted from the data flow**: data moves site→central, but each **site node hosts the gRPC server** (`SiteStreamGrpcServer`, Kestrel h2c, port 8083, mapped **only in the Site branch** of `Program.cs`) and **central is the client**, dialling in. Central creates per-site `SiteStreamGrpcClient` via `SiteStreamGrpcClientFactory`, keyed **`(siteId, endpoint)`** — the key was widened from site-only to fix an arch-review High where one session's NodeA→NodeB flip disposed a channel another session was still using. Proto evolution is **additive only** and field numbers are never reused (`AlarmStateUpdate` grew 7→23 fields for the native-alarm mirror). Generated C# is **vendored** under `Communication/SiteStreamGrpc/` with the `<Protobuf>` include commented out — regeneration is a manual toggle-build-copy-untoggle.
|
||||||
- gRPC streaming channel — **note the direction is inverted from the data flow**: data moves site→central, but each **site node hosts the gRPC server** (`SiteStreamGrpcServer`, Kestrel h2c, port 8083, mapped **only in the Site branch** of `Program.cs`) and **central is the client**, dialling in. There is **no gRPC server on central at all**, which is why the two `Ingest*` unary RPCs — documented as a "central-side ingest surface" — are dead in practice (acknowledged in `AkkaHostedService.cs:510-519`); sites reach central over ClusterClient instead. Central creates per-site `SiteStreamGrpcClient` via `SiteStreamGrpcClientFactory`, keyed **`(siteId, endpoint)`** — the key was widened from site-only to fix an arch-review High where one session's NodeA→NodeB flip disposed a channel another session was still using. Site entity has GrpcNodeAAddress/GrpcNodeBAddress fields. Proto: `sitestream.proto`, **6 RPCs** (2 server-streaming: `SubscribeInstance`, `SubscribeSite` (site-wide, alarm-only); 4 unary: `IngestAuditEvents`, `IngestCachedTelemetry`, `PullAuditEvents`, `PullSiteCalls`), `SiteStreamEvent` (oneof: AttributeValueUpdate, AlarmStateUpdate). Field numbers are never reused; evolution is additive only (`AlarmStateUpdate` grew 7→23 fields for the native-alarm mirror). Generated C# is **vendored** under `Communication/SiteStreamGrpc/` with the `<Protobuf>` include commented out — regeneration is a manual toggle-build-copy-untoggle. DebugStreamEvent message removed (no longer flows through ClusterClient).
|
- Native alarms are a **read-only** mirror of OPC UA Alarms & Conditions and MxAccess Gateway alarms — **no ack-back, no central tables**; state lives in the site's `native_alarm_state`, survives failover, and is cleared on redeploy/undeploy (mirrors static overrides). Central's per-site live alarm cache (`ISiteAlarmLiveCache`) is **transient in-memory only** — there is deliberately no persisted central alarm store, so the 15s poll remains the NotReporting authority behind the live stream. See `Component-DataConnectionLayer.md` / `Component-CentralUI.md` for the model and the authoring surface.
|
||||||
- Native alarms: a read-only mirror of native alarms from OPC UA Alarms & Conditions servers and the MxAccess Gateway, unified onto an A&C-style condition model (`AlarmConditionState`: orthogonal Active/Acked/Confirmed/Shelved/Suppressed + 0–1000 severity) plus an `AlarmKind` discriminator (Computed/NativeOpcUa/NativeMxAccess). New DCL capability seam `IAlarmSubscribableConnection` (implemented by the OPC UA and MxGateway adapters); the `DataConnectionActor` opens ONE alarm feed per connection and routes transitions to instances by source-object reference. A `NativeAlarmActor` (peer to the computed `AlarmActor` under `InstanceActor`) mirrors one source binding: snapshot atomic-swap on (re)subscribe, retention (drops once inactive+acked), per-source cap, and site SQLite persistence (`native_alarm_state`, survives failover, cleared on redeploy/undeploy — mirrors static overrides). State streams to central over the additively-enriched gRPC `AlarmStateUpdate` (the existing computed `AlarmStateChanged` was enriched additively) and seeds via the DebugView snapshot. Authoring: `TemplateNativeAlarmSource` / `InstanceNativeAlarmSourceOverride` entities flatten to `ResolvedNativeAlarmSource` (inherit/compose/override); management commands + ManagementActor handlers + CLI (`template/instance native-alarm-source`) + Central UI (template editor tab + instance override panel) + enriched DebugView alarm table. Read-only — no ack-back; no central tables.
|
- **`AckTime` mirror enrichment + the `Alarms` script accessor (MES alarm-status API Phase 1, 2026-08-01).** `AlarmStateChanged` carries an additive `AckTime` (`DateTimeOffset?`), mirrored on the vendored `AlarmStateUpdate` proto as **field 24** and persisted inside `native_alarm_state`'s `metadata_json` — deliberately NOT a new column, because that table is `RegisterReplicated` and LocalDb builds its CDC triggers from the column list at registration time. Set only while a condition is active AND acknowledged (so it is null while unacked and cleared on re-raise); the DCL stamps the source's own ack instant for OPC UA (new SelectClause **index 18** = `AckedState/TransitionTime`) and its observation time of the ack transition for MxGateway, which supplies none. Site `Call` scripts read alarms via the new **`Alarms.CurrentAsync()`** accessor (`ScriptRuntimeContext` + `ScriptGlobals`, local Ask on `GetAlarmSnapshotRequest`, returns `Commons.Types.Scripts.ScriptAlarm`), mirrored on `ScriptCompileSurface` AND the Central UI `SandboxScriptHost` editor surface. The trust model needed no change — it is a deny-list over API roots, not an allow-list of context members. Plan: `docs/plans/2026-06-30-mes-alarm-status-api.md` (Phases 2–4 are deployed config, not repo).
|
||||||
- OPC UA / MxGateway UX (M7): operator **Alarm Summary** page (`/monitoring/alarms`, RequireDeployment, read-only) fans out the existing per-instance `DebugViewSnapshot` Ask (SemaphoreSlim-capped, partial-results tolerant) and aggregates client-side — no central alarm store; shared `AlarmStateBadges` component. **Aggregated live stream shipped 2026-07-10** (`docs/plans/2026-07-10-aggregated-live-alarm-stream-plan.md`): a transient in-memory per-site central live cache (`ISiteAlarmLiveCache`) fed by a site-wide, alarm-only `SubscribeSite` gRPC stream (seed-then-stream), pushing near-real-time deltas to the page over the Blazor circuit with the 15s poll kept as fallback + NotReporting authority — still no persisted central alarm store. OPC UA node browser gains `BrowseNext` continuation paging ("Load more"), a bounded recursive address-space **search** (`IAddressSpaceSearchable` seam; depth + result caps; substring on DisplayName/path), and **type-info** (DataType/ValueRank/Writable on `BrowseNode` for Variables). Attribute-override **CSV bulk import** (`OverrideCsvParser`, all-or-nothing) via InstanceConfigure `InputFile` + CLI `instance import-overrides --file` (native-alarm-source-override CSV deferred). **Verify-endpoint** probe (temporary `RealOpcUaClient`, short timeout, captures an untrusted server cert but NEVER trusts it) + **site-local cert trust**: per-node `CertStoreActor` (runs on every site node, not a singleton) writing the `.der` into the node's OPC UA trusted-peer PKI store; DeploymentManager broadcasts `TrustServerCertCommand`/`RemoveServerCertCommand` to BOTH site nodes so PKI stores stay consistent across failover; Admin-gated cert-management UI (`/design/connections/{id}/certificates`). No central persistence of cert trust (follow-up).
|
- OPC UA cert trust is **site-local and not persisted centrally** (follow-up): the verify-endpoint probe captures an untrusted server cert but **NEVER trusts it**, and DeploymentManager broadcasts `TrustServerCertCommand`/`RemoveServerCertCommand` to **BOTH** site nodes — `CertStoreActor` runs on every site node, not as a singleton, so PKI stores stay consistent across failover.
|
||||||
|
|
||||||
### External Integrations
|
### External Integrations
|
||||||
- External System Gateway: HTTP/REST only, JSON serialization, API key + Basic Auth.
|
- External System Gateway: HTTP/REST only, JSON serialization, API key + Basic Auth.
|
||||||
- Dual call modes: `ExternalSystem.Call()` (synchronous) and `ExternalSystem.CachedCall()` (store-and-forward on transient failure).
|
- Dual call modes: `ExternalSystem.Call()` (synchronous) and `ExternalSystem.CachedCall()` (store-and-forward on transient failure).
|
||||||
- Error classification: HTTP 5xx/408/429/connection errors = transient; other 4xx = permanent (returned to script).
|
- Error classification: HTTP 5xx/408/429/connection errors = transient; other 4xx = permanent (returned to script).
|
||||||
- Notification Service: SMTP with OAuth2 Client Credentials (Microsoft 365) or Basic Auth. BCC delivery, plain text.
|
- Notification Service: SMTP with OAuth2 Client Credentials (Microsoft 365) or Basic Auth. BCC delivery, plain text.
|
||||||
|
- Email delivery has **two transports**, selected per config row by `SmtpConfiguration.Transport` (null/`Smtp` = default): SMTP (MailKit, Basic/OAuth2) or on-prem Exchange **EWS** (no-SDK `CreateItem` SOAP over `HttpClient`, Basic-over-HTTPS only — https enforced at write gate, adapter AND sender — BCC-only recipients, `SendOnly` so no Sent-Items copy). Under `Ews`, `Host` is the full EWS URL, `Credentials` is `username:password`, and Port/TlsMode/OAuth2*/MaxConcurrentConnections are unused. Site-facing behavior is unchanged (delivery stays central-only). Q12 (M365 OAuth2 tenant) is closed as **superseded** — the OAuth2 SMTP path stays config-selectable but untested against a live tenant. Design: `docs/plans/2026-08-10-ews-email-transport-design.md`.
|
||||||
- Notification delivery is central-only: sites store-and-forward notifications to the central cluster (target = central, not SMTP); sites never talk to SMTP. Notification lists and SMTP config are no longer deployed to sites; recipient resolution happens at central, at delivery time.
|
- Notification delivery is central-only: sites store-and-forward notifications to the central cluster (target = central, not SMTP); sites never talk to SMTP. Notification lists and SMTP config are no longer deployed to sites; recipient resolution happens at central, at delivery time.
|
||||||
- Notification lists carry a `Type` discriminator (`Email` and `Sms`). `Notify.To("list")` is type-agnostic; delivery is via per-type `INotificationDeliveryAdapter` (Email via SMTP; Sms via Twilio REST — `SmsNotificationDeliveryAdapter`, no SDK, one POST per recipient, per-recipient rollup). List Type is fixed after creation.
|
- Notification lists carry a `Type` discriminator (`Email` and `Sms`). `Notify.To("list")` is type-agnostic; delivery is via per-type `INotificationDeliveryAdapter` (Email via SMTP; Sms via Twilio REST — `SmsNotificationDeliveryAdapter`, no SDK, one POST per recipient, per-recipient rollup). List Type is fixed after creation.
|
||||||
- `Notify.Send` is async and **enqueue-only** — it buffers the notification into the local SQLite S&F store and returns a `NotificationId` (GUID, idempotency key) status handle immediately; it never runs the forwarder's central Ask inline on the script thread (`deferToSweep: true` buffers due-immediately + kicks a background sweep), so its worst-case latency is the local insert whether central is up or down. It enqueues with `maxRetries: 0` (the "no limit" escape hatch), so notifications retry until central acks and are **never parked for retry exhaustion** — only a corrupt payload parks them (arch-review 02, Tasks 13/14). `Notify.Status(notificationId)` returns a status record (status, retry count, last error, key timestamps); answered site-locally as `Forwarding` while still in the site S&F buffer, otherwise round-trips to central.
|
- `Notify.Send` is async and **enqueue-only** — it buffers the notification into the local SQLite S&F store and returns a `NotificationId` (GUID, idempotency key) status handle immediately; it never runs the forwarder's central Ask inline on the script thread (`deferToSweep: true` buffers due-immediately + kicks a background sweep), so its worst-case latency is the local insert whether central is up or down. It enqueues with `maxRetries: 0` (the "no limit" escape hatch), so notifications retry until central acks and are **never parked for retry exhaustion** — only a corrupt payload parks them (arch-review 02, Tasks 13/14). `Notify.Status(notificationId)` returns a status record (status, retry count, last error, key timestamps); answered site-locally as `Forwarding` while still in the site S&F buffer, otherwise round-trips to central.
|
||||||
@@ -193,7 +151,7 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
|
|||||||
- Scope = script trust boundary: outbound API (sync + cached), outbound DB (sync + cached), notifications, inbound API. Framework/internal traffic is explicitly excluded.
|
- Scope = script trust boundary: outbound API (sync + cached), outbound DB (sync + cached), notifications, inbound API. Framework/internal traffic is explicitly excluded.
|
||||||
- One row per lifecycle event; cached calls produce 4+ rows per operation (`Submitted`, `Forwarded`, `Attempted`, `Delivered`/`Parked`/`Discarded`).
|
- One row per lifecycle event; cached calls produce 4+ rows per operation (`Submitted`, `Forwarded`, `Attempted`, `Delivered`/`Parked`/`Discarded`).
|
||||||
- `ExecutionId` (`uniqueidentifier NULL`) is the universal per-run correlation value — every audit row emitted by one script execution / inbound request shares it; `CorrelationId` remains the per-operation lifecycle id (NULL for sync one-shots).
|
- `ExecutionId` (`uniqueidentifier NULL`) is the universal per-run correlation value — every audit row emitted by one script execution / inbound request shares it; `CorrelationId` remains the per-operation lifecycle id (NULL for sync one-shots).
|
||||||
- `ParentExecutionId` (`uniqueidentifier NULL`) is the cross-execution spawn pointer — every row of a spawned run carries the spawner's `ExecutionId`; bridges inbound API → routed-site-script, alarm-triggered on-trigger scripts, and nested `CallScript`/`CallShared` invocations; `IX_AuditLog_ParentExecution` backs the filter + the recursive execution-tree walk. Tag-cascade coverage is complete as of M5.4 (T4) — no further spawn points are deferred.
|
- `ParentExecutionId` (`uniqueidentifier NULL`) is the cross-execution spawn pointer — every row of a spawned run carries the spawner's `ExecutionId`; bridges inbound API → routed-site-script, alarm-triggered on-trigger scripts, and nested `CallScript`/`CallShared` invocations; `IX_AuditLog_ParentExecution` backs the filter + the recursive execution-tree walk. **Tag-cascade (alarm leg) is populated, not just plumbed** — M5.4 T4 threaded the `parentExecutionId` parameter but every `AlarmActor.SpawnAlarmExecution` call site passed null, so alarm runs were silently always roots; the id now rides site-locally as `SetStaticAttributeCommand.SourceExecutionId` → `AttributeValueChanged.SourceExecutionId` → `SpawnAlarmExecution` (all additive/nullable, no wire/proto/schema change; `Expression` triggers capture the writer *with* the evaluated snapshot since the eval completes off-dispatcher). Sources are `ScriptRuntimeContext.SetAttribute` (the run's own `ExecutionId`) and `Route.To(...).SetAttributes(...)` (the inbound request's). **Still roots by design, not omission:** alarms fired by DCL data (external values have no spawning execution — including the device echo of a script write to a *data-sourced* attribute, so only **static** writes cascade), and `ScriptActor` value-change/conditional/expression/timer trigger runs (a timer tick has no spawner; a `WhileTrue`/interval run has no single identifiable write).
|
||||||
- Site SQLite hot-path first, then gRPC telemetry to central; ingest is idempotent on `EventId`; periodic reconciliation pull as fallback when telemetry is lost.
|
- Site SQLite hot-path first, then gRPC telemetry to central; ingest is idempotent on `EventId`; periodic reconciliation pull as fallback when telemetry is lost.
|
||||||
- Cached operations: site emits a single additively-extended `CachedCallTelemetry` packet carrying both audit events and operational state; central writes `AuditLog` + `SiteCalls` in one transaction.
|
- Cached operations: site emits a single additively-extended `CachedCallTelemetry` packet carrying both audit events and operational state; central writes `AuditLog` + `SiteCalls` in one transaction.
|
||||||
- Payload cap 8 KB by default / 64 KB on error rows; auth headers redacted by default; SQL parameter values captured by default; per-target redaction opt-in. Inbound API: full verbatim capture up to `InboundMaxBytes` (default 1 MiB); request headers stored in `Extra.requestHeaders` (post-redaction); per-method `SkipBodyCapture` flag suppresses bodies while still recording headers + metadata; `AuditInboundCeilingHits` counter surfaced on health snapshot. (M5.3 T7)
|
- Payload cap 8 KB by default / 64 KB on error rows; auth headers redacted by default; SQL parameter values captured by default; per-target redaction opt-in. Inbound API: full verbatim capture up to `InboundMaxBytes` (default 1 MiB); request headers stored in `Extra.requestHeaders` (post-redaction); per-method `SkipBodyCapture` flag suppresses bodies while still recording headers + metadata; `AuditInboundCeilingHits` counter surfaced on health snapshot. (M5.3 T7)
|
||||||
@@ -218,13 +176,14 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
|
|||||||
- Failure detection: 2s heartbeat, 10s threshold. Total failover ~25s (drill-measured 2026-07-21 under auto-down: active-crash TAKEOVER in 28s, standby-crash removal in 27s with 0 routing blips — `docker/failover-drill.sh`).
|
- Failure detection: 2s heartbeat, 10s threshold. Total failover ~25s (drill-measured 2026-07-21 under auto-down: active-crash TAKEOVER in 28s, standby-crash removal in 27s with 0 routing blips — `docker/failover-drill.sh`).
|
||||||
- CoordinatedShutdown for graceful singleton handover.
|
- CoordinatedShutdown for graceful singleton handover.
|
||||||
- Automatic dual-node recovery from persistent storage.
|
- Automatic dual-node recovery from persistent storage.
|
||||||
- **Active/standby is decided by `ActiveNodeEvaluator.SelfIsOldestUp`, never by cluster leadership** — see the Architecture note above. `/health/active` is **central-only** (site nodes map no `/health/*` at all) and backs both Traefik's active-node routing and `IActiveNodeGate`, so the proxy and the Inbound API always agree on which node is active. Central never needs to know which *site* node is active: ClusterClient contact rotation reaches either receptionist and the site-internal `ClusterSingletonProxy` lands the work on the active node for free. The **exception is gRPC**, which picks `GrpcNodeAAddress`/`GrpcNodeBAddress` explicitly and flips on error.
|
- **Active/standby is decided by `ActiveNodeEvaluator.SelfIsOldestUp`, never by cluster leadership** — see the Architecture note above. `/health/active` is **central-only** (site nodes map no `/health/*` at all) and backs both Traefik's active-node routing and `IActiveNodeGate`, so the proxy and the Inbound API always agree on which node is active. Central reaches a site by dialling `GrpcNodeAAddress`/`GrpcNodeBAddress` explicitly and flipping on error; whichever node answers, the site-internal `ClusterSingletonProxy` lands the work on the active node for free, so central still never needs to track which *site* node is active.
|
||||||
- **Seed-node ordering: every node lists ITSELF first (decision 2026-07-22) — the boot-alone gap is CLOSED.** Only `seed-nodes[0]` may self-join to form a new cluster (Akka runs `FirstSeedNodeProcess` for it, `JoinSeedNodeProcess` — which can never form one — for everyone else). All 14 shipped node appsettings now lead with the node's own address, so any node can cold-start alone and become operational unattended (~5s, `seed-node-timeout`); `StartupValidator` fails the boot if the ordering is broken (compares host AND port; Akka does no DNS canonicalisation). Two nodes cold-starting together while mutually reachable converge on ONE cluster via the `InitJoin` handshake — they split only under a genuine boot-time partition, the same class auto-down accepts. **An external self-form timer (`Cluster.Join(SelfAddress)` after a window) was implemented and REJECTED:** it sits outside the join handshake, so on a routine standby restart — where the peer is alive but the join is stalled behind removal of the node's own stale incarnation — it fires mid-join and permanently splits the pair (measured: still split after 90s). Regression tests: `SelfFirstSeedBootstrapTests`. The keep-oldest active-crash total outage was separately closed by the auto-down decision. See `docs/requirements/Component-ClusterInfrastructure.md` → Seed Node Ordering.
|
- **Seed-node ordering: every node lists ITSELF first (decision 2026-07-22) — the boot-alone gap is CLOSED.** Only `seed-nodes[0]` may self-join to form a new cluster (Akka runs `FirstSeedNodeProcess` for it, `JoinSeedNodeProcess` — which can never form one — for everyone else). All 14 shipped node appsettings now lead with the node's own address, so any node can cold-start alone and become operational unattended (~5s, `seed-node-timeout`); `StartupValidator` fails the boot if the ordering is broken (compares host AND port; Akka does no DNS canonicalisation). Two nodes cold-starting together while mutually reachable converge on ONE cluster via the `InitJoin` handshake — they split only under a genuine boot-time partition, the same class auto-down accepts. **An external self-form timer (`Cluster.Join(SelfAddress)` after a window) was implemented and REJECTED:** it sits outside the join handshake, so on a routine standby restart — where the peer is alive but the join is stalled behind removal of the node's own stale incarnation — it fires mid-join and permanently splits the pair (measured: still split after 90s). Regression tests: `SelfFirstSeedBootstrapTests`. The keep-oldest active-crash total outage was separately closed by the auto-down decision. See `docs/requirements/Component-ClusterInfrastructure.md` → Seed Node Ordering.
|
||||||
|
- **Simultaneous-cold-start split-brain guard (opt-in, Gitea #33) — the residual self-first cost, closed.** Self-first-on-both means a *truly simultaneous* cold start (shared power/hypervisor event) races `FirstSeedNodeProcess` on BOTH nodes → two 1-node clusters that never merge (in-process loopback tests converge and hide it; real parallel VM starts don't — OtOpcUa reproduced it live). Ported from OtOpcUa (`lmxopcua` `d1dac87f`): a **dark switch `ScadaBridge:Cluster:BootstrapGuard:Enabled` (default OFF, guard-off byte-identical)**. On: `BuildHocon` emits an EMPTY seed list (Akka does not auto-join) and `ClusterBootstrapCoordinator` (`IHostedService`, registered in BOTH the Central and Site composition roots) issues ONE reachability-gated `JoinSeedNodes` — the pure `ClusterBootstrapGuard` core makes the lower canonical `host:port` the **founder** (self-first, forms immediately), the higher node TCP-probes the founder up to `PartnerProbeSeconds` (25s) and joins **peer-first** if reachable else **self-first** (cold-start-alone preserved). Decided BEFORE a single join, **never re-forms mid-handshake** (that was the rejected self-form-timer's flaw); case-insensitive founder tie-break; probe timings validated `>0` when enabled. Residual accepted trade: founder dies in the probe→join window → higher node hangs unjoined, coordinator WARNs, a restart recovers it. Tests: `ClusterBootstrapGuardTests` (pure) + `ClusterBootstrapCoordinatorTests` (real-ActorSystem, incl. both-cold-start-together-form-one-cluster). **ENABLED on the docker rig (all 8 nodes) 2026-08-02** after `deploy.sh`'s simultaneous recreate split site pairs twice on 2026-08-01 with the guard off; live gate PASS — two consecutive simultaneous-start trials (a full redeploy + a full-topology `docker compose restart`), all four pairs converged (every lower-address node founded self-first, every higher node probed-then-joined peer-first), zero splits. Default remains OFF elsewhere (wonder-app-vd03, docker-env2, Host defaults). See `docs/requirements/Component-ClusterInfrastructure.md` → Simultaneous cold start.
|
||||||
|
|
||||||
### UI & Monitoring
|
### UI & Monitoring
|
||||||
- Central UI: Blazor Server (ASP.NET Core + SignalR) with Bootstrap CSS. No third-party component frameworks (no Blazorise, MudBlazor, Radzen, etc.). Build custom Blazor components for tables, grids, forms, etc.
|
- Central UI: Blazor Server (ASP.NET Core + SignalR) with Bootstrap CSS. No third-party component frameworks (no Blazorise, MudBlazor, Radzen, etc.). Build custom Blazor components for tables, grids, forms, etc.
|
||||||
- UI design: Clean, corporate, internal-use aesthetic. Not flashy. Use the `frontend-design` skill when designing UI pages/components.
|
- UI design: Clean, corporate, internal-use aesthetic. Not flashy. Use the `frontend-design` skill when designing UI pages/components.
|
||||||
- Debug view: real-time streaming via DebugStreamBridgeActor + gRPC (events via SiteStreamGrpcClient, snapshot via ClusterClient). Health dashboard: 10s polling timer. Deployment status: real-time push via SignalR.
|
- Debug view: real-time streaming via DebugStreamBridgeActor + gRPC (events via `SiteStreamGrpcClient`, snapshot via the `SiteCommandService` query surface — `QueryReply.DebugViewSnapshot`). Health dashboard: 10s polling timer. Deployment status: real-time push via SignalR.
|
||||||
- Health reports: 30s interval, 60s offline threshold, monotonic sequence numbers, raw error counts per interval.
|
- Health reports: 30s interval, 60s offline threshold, monotonic sequence numbers, raw error counts per interval.
|
||||||
- Dead letter monitoring as a health metric.
|
- Dead letter monitoring as a health metric.
|
||||||
- Site Event Logging: 30-day retention, 1GB storage cap, daily purge, paginated queries with keyword search.
|
- Site Event Logging: 30-day retention, 1GB storage cap, daily purge, paginated queries with keyword search.
|
||||||
@@ -247,20 +206,15 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
|
|||||||
|
|
||||||
### Akka.NET Conventions
|
### Akka.NET Conventions
|
||||||
- Tell for hot-path internal communication; Ask reserved for system boundaries.
|
- Tell for hot-path internal communication; Ask reserved for system boundaries.
|
||||||
- ClusterClient for cross-cluster communication; ClusterClientReceptionist for service discovery across cluster boundaries.
|
- Cross-cluster communication is gRPC (per-site PSK-authenticated): site→central `CentralControlService`, central→site `SiteCommandService`, plus the `SiteStreamService` data stream. ClusterClient/ClusterClientReceptionist were removed in the migration's Phase 4 — service discovery is by dialling configured endpoints, not the receptionist. (Akka.Cluster.Tools remains for ClusterSingleton.)
|
||||||
- Script trust model: forbidden APIs (System.IO, Process, Threading, Reflection, raw network). The trust boundary is centralized in the Script Analysis component (#25) — `ScriptTrustPolicy` is the single source of truth; all four call sites (Template Engine, Site Runtime, Inbound API, Central UI) delegate to `ScriptTrustValidator`. The design-time deploy gate in Template Engine is authoritative (real semantic compile), not advisory.
|
- Script trust model: forbidden APIs (System.IO, Process, Threading, Reflection, raw network). The trust boundary is centralized in the Script Analysis component (#25) — `ScriptTrustPolicy` is the single source of truth; all four call sites (Template Engine, Site Runtime, Inbound API, Central UI) delegate to `ScriptTrustValidator`. The design-time deploy gate in Template Engine is authoritative (real semantic compile), not advisory.
|
||||||
- Application-level correlation IDs on all request/response messages.
|
- Application-level correlation IDs on all request/response messages.
|
||||||
|
|
||||||
## Tool Usage
|
## Tool Usage
|
||||||
|
|
||||||
- When consulting with the Codex MCP tool, use model `gpt-5.4`.
|
|
||||||
- When a task requires setting up or controlling system state (sites, templates, instances, data connections, deployments, security, etc.) and the Central UI is not needed, prefer the ScadaBridge CLI over manual DB edits or UI navigation. See [`src/ZB.MOM.WW.ScadaBridge.CLI/README.md`](src/ZB.MOM.WW.ScadaBridge.CLI/README.md) for the full command reference.
|
- When a task requires setting up or controlling system state (sites, templates, instances, data connections, deployments, security, etc.) and the Central UI is not needed, prefer the ScadaBridge CLI over manual DB edits or UI navigation. See [`src/ZB.MOM.WW.ScadaBridge.CLI/README.md`](src/ZB.MOM.WW.ScadaBridge.CLI/README.md) for the full command reference.
|
||||||
|
|
||||||
### CLI Quick Reference (Docker / OrbStack)
|
### CLI Quick Reference (Docker / OrbStack)
|
||||||
|
|
||||||
- **Management URL**: `http://localhost:9000` — the CLI connects via the Traefik load balancer, which routes to the active central node. Direct access: central-a on port 9001, central-b on port 9002.
|
Management URL, test credentials, rebuild/redeploy commands and the infra services are in the
|
||||||
- **Test user**: `--username multi-role --password password` — has Admin, Design, and Deployment roles. The `admin` user only has the Admin role and cannot create templates, data connections, or deploy.
|
`scadabridge-cluster-ops` skill (`.claude/skills/scadabridge-cluster-ops/SKILL.md`).
|
||||||
- **Config file**: `~/.scadabridge/config.json` — stores `managementUrl` and default format. See `docker/README.md` for a ready-to-use test config.
|
|
||||||
- **Rebuild cluster**: `bash docker/deploy.sh` — builds the `scadabridge:latest` image and recreates all containers. Run this after code changes to ManagementActor, Host, or any server-side component.
|
|
||||||
- **Infrastructure services**: `cd infra && docker compose up -d` — starts MS SQL, OPC UA, SMTP, and REST API. These are separate from the cluster containers in `docker/`. **LDAP is NOT started here** — it is the shared GLAuth on `10.100.0.35:3893` (dc=zb,dc=local); source of truth and config: `scadaproj/infra/glauth/`.
|
|
||||||
- **All test LDAP passwords**: `password` (see `scadaproj/infra/glauth/config.toml` for users and groups; canonical cross-app login: `multi-role`).
|
|
||||||
|
|||||||
@@ -90,9 +90,9 @@
|
|||||||
to mark tests as Skipped (not silently Passed) when MSSQL is unreachable.
|
to mark tests as Skipped (not silently Passed) when MSSQL is unreachable.
|
||||||
-->
|
-->
|
||||||
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
|
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.3.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.3.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.3.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.1" />
|
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.1" />
|
||||||
@@ -103,11 +103,12 @@
|
|||||||
<PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.5" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.5" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.5" />
|
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.5" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.4.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.2.3" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.5.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.2.3" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.5.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.2.3" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.5.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.SqlServer" Version="0.2.3" />
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.SqlServer" Version="0.5.1" />
|
||||||
|
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.Grpc" Version="0.5.1" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.1.3" />
|
<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.1.3" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.1.3" />
|
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.1.3" />
|
||||||
<PackageVersion Include="ZB.MOM.WW.LocalDb.Contracts" Version="0.1.3" />
|
<PackageVersion Include="ZB.MOM.WW.LocalDb.Contracts" Version="0.1.3" />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ This repository is the full **implementation** project for ScadaBridge — the C
|
|||||||
| Central Database | MS SQL Server, Entity Framework Core |
|
| Central Database | MS SQL Server, Entity Framework Core |
|
||||||
| Site Storage | SQLite (deployed configs, S&F buffer, event logs) |
|
| Site Storage | SQLite (deployed configs, S&F buffer, event logs) |
|
||||||
| Authentication | Direct LDAP/AD bind (LDAPS/StartTLS), JWT sessions |
|
| Authentication | Direct LDAP/AD bind (LDAPS/StartTLS), JWT sessions |
|
||||||
| Notifications | Delivered from the central cluster (Email via SMTP/OAuth2-M365; SMS via Twilio REST); store-and-forwarded from sites |
|
| Notifications | Delivered from the central cluster (Email via SMTP/OAuth2-M365 or on-prem Exchange EWS; SMS via Twilio REST); store-and-forwarded from sites |
|
||||||
| Hosting | Windows Server, Windows Service |
|
| Hosting | Windows Server, Windows Service |
|
||||||
| Cluster | Akka.NET Cluster (active/standby, keep-oldest SBR) |
|
| Cluster | Akka.NET Cluster (active/standby, keep-oldest SBR) |
|
||||||
| Logging | Serilog (structured) |
|
| Logging | Serilog (structured) |
|
||||||
@@ -83,7 +83,7 @@ Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDA
|
|||||||
| 5 | Central–Site Communication | [docs/requirements/Component-Communication.md](docs/requirements/Component-Communication.md) | Dual transport: Akka.NET ClusterClient (command/control) + gRPC server-streaming (real-time data). 9 message patterns with per-pattern timeouts, SiteStreamGrpcServer/Client, application-level correlation IDs, transport heartbeat config, gRPC keepalive, message ordering, connection failure behavior. The gRPC stream additively carries the read-only native alarm mirror (computed + native OPC UA / MxAccess) via the enriched `AlarmStateUpdate`. |
|
| 5 | Central–Site Communication | [docs/requirements/Component-Communication.md](docs/requirements/Component-Communication.md) | Dual transport: Akka.NET ClusterClient (command/control) + gRPC server-streaming (real-time data). 9 message patterns with per-pattern timeouts, SiteStreamGrpcServer/Client, application-level correlation IDs, transport heartbeat config, gRPC keepalive, message ordering, connection failure behavior. The gRPC stream additively carries the read-only native alarm mirror (computed + native OPC UA / MxAccess) via the enriched `AlarmStateUpdate`. |
|
||||||
| 6 | Store-and-Forward Engine | [docs/requirements/Component-StoreAndForward.md](docs/requirements/Component-StoreAndForward.md) | Buffering (transient failures only), fixed-interval retry, parking, async best-effort replication, SQLite persistence at sites. |
|
| 6 | Store-and-Forward Engine | [docs/requirements/Component-StoreAndForward.md](docs/requirements/Component-StoreAndForward.md) | Buffering (transient failures only), fixed-interval retry, parking, async best-effort replication, SQLite persistence at sites. |
|
||||||
| 7 | External System Gateway | [docs/requirements/Component-ExternalSystemGateway.md](docs/requirements/Component-ExternalSystemGateway.md) | HTTP/REST + JSON, API key/Basic Auth, per-system timeout, dual call modes (Call/CachedCall), transient/permanent error classification, dedicated blocking I/O dispatcher, ADO.NET connection pooling. |
|
| 7 | External System Gateway | [docs/requirements/Component-ExternalSystemGateway.md](docs/requirements/Component-ExternalSystemGateway.md) | HTTP/REST + JSON, API key/Basic Auth, per-system timeout, dual call modes (Call/CachedCall), transient/permanent error classification, dedicated blocking I/O dispatcher, ADO.NET connection pooling. |
|
||||||
| 8 | Notification Service | [docs/requirements/Component-NotificationService.md](docs/requirements/Component-NotificationService.md) | Central-only — manages typed notification-list, SMTP, and SMS definitions; supplies per-type delivery adapters (Email via SMTP with OAuth2 (M365) or Basic Auth, BCC, plain text; SMS via Twilio REST, per-recipient, outbound-only); delivery performed by the Notification Outbox. |
|
| 8 | Notification Service | [docs/requirements/Component-NotificationService.md](docs/requirements/Component-NotificationService.md) | Central-only — manages typed notification-list, SMTP, and SMS definitions; supplies per-type delivery adapters (Email via SMTP with OAuth2 (M365) or Basic Auth, or on-prem Exchange EWS, BCC, plain text; SMS via Twilio REST, per-recipient, outbound-only); delivery performed by the Notification Outbox. |
|
||||||
| 9 | Central UI | [docs/requirements/Component-CentralUI.md](docs/requirements/Component-CentralUI.md) | Blazor Server with SignalR real-time push, load balancer failover with JWT, all management workflows. Custom-content modal host (`DialogService.ShowAsync<T>`) with focus-trap/restore; dark-mode CSS-variable token layer (`[data-bs-theme="dark"]` overriding `ZB.MOM.WW.Theme` tokens in `site.css`, `localStorage`-persisted, SSR no-flash); reusable presentational components `OffsetPager`, `KeysetPager`, and `DateTimeRangeFilter` adopted across report/audit pages. |
|
| 9 | Central UI | [docs/requirements/Component-CentralUI.md](docs/requirements/Component-CentralUI.md) | Blazor Server with SignalR real-time push, load balancer failover with JWT, all management workflows. Custom-content modal host (`DialogService.ShowAsync<T>`) with focus-trap/restore; dark-mode CSS-variable token layer (`[data-bs-theme="dark"]` overriding `ZB.MOM.WW.Theme` tokens in `site.css`, `localStorage`-persisted, SSR no-flash); reusable presentational components `OffsetPager`, `KeysetPager`, and `DateTimeRangeFilter` adopted across report/audit pages. |
|
||||||
| 10 | Security & Auth | [docs/requirements/Component-Security.md](docs/requirements/Component-Security.md) | Direct LDAP bind (LDAPS/StartTLS), JWT sessions (HMAC-SHA256, 15-min refresh, 30-min idle), role-based authorization (incl. the `Operator`/`Verifier` two-person secured-write roles + policies), site-scoped permissions. |
|
| 10 | Security & Auth | [docs/requirements/Component-Security.md](docs/requirements/Component-Security.md) | Direct LDAP bind (LDAPS/StartTLS), JWT sessions (HMAC-SHA256, 15-min refresh, 30-min idle), role-based authorization (incl. the `Operator`/`Verifier` two-person secured-write roles + policies), site-scoped permissions. |
|
||||||
| 11 | Health Monitoring | [docs/requirements/Component-HealthMonitoring.md](docs/requirements/Component-HealthMonitoring.md) | 30s report interval, 60s offline threshold, monotonic sequence numbers, raw error counts, tag resolution counts, dead letter monitoring. |
|
| 11 | Health Monitoring | [docs/requirements/Component-HealthMonitoring.md](docs/requirements/Component-HealthMonitoring.md) | 30s report interval, 60s offline threshold, monotonic sequence numbers, raw error counts, tag resolution counts, dead letter monitoring. |
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
# Documentation Analysis Report
|
|
||||||
|
|
||||||
Files Scanned: 793
|
|
||||||
Files With Issues: 10
|
|
||||||
Total Issues: 11
|
|
||||||
|
|
||||||
## Issues
|
|
||||||
|
|
||||||
FILE: /Users/dohertj2/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/BrowserTime.cs
|
|
||||||
LINE: 26
|
|
||||||
CATEGORY: TaskReferenceInComment
|
|
||||||
SEVERITY: Warning
|
|
||||||
MEMBER: Comment
|
|
||||||
SIGNATURE: UTC-5
|
|
||||||
MESSAGE: Comment contains 'UTC-5', which looks like a task/issue tracking identifier; tracking IDs should not appear in code documentation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
FILE: /Users/dohertj2/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs
|
|
||||||
LINE: 225
|
|
||||||
CATEGORY: TaskReferenceInComment
|
|
||||||
SEVERITY: Warning
|
|
||||||
MEMBER: Comment
|
|
||||||
SIGNATURE: Step-2
|
|
||||||
MESSAGE: Comment contains 'Step-2', which looks like a task/issue tracking identifier; tracking IDs should not appear in code documentation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
FILE: /Users/dohertj2/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ConnectionHealthQueryService.cs
|
|
||||||
LINE: 18
|
|
||||||
CATEGORY: TaskReferenceInComment
|
|
||||||
SEVERITY: Warning
|
|
||||||
MEMBER: Comment
|
|
||||||
SIGNATURE: PLC-1
|
|
||||||
MESSAGE: Comment contains 'PLC-1', which looks like a task/issue tracking identifier; tracking IDs should not appear in code documentation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
FILE: /Users/dohertj2/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditExportHelpers.cs
|
|
||||||
LINE: 156
|
|
||||||
CATEGORY: TaskReferenceInComment
|
|
||||||
SEVERITY: Warning
|
|
||||||
MEMBER: Comment
|
|
||||||
SIGNATURE: non-403
|
|
||||||
MESSAGE: Comment contains 'non-403', which looks like a task/issue tracking identifier; tracking IDs should not appear in code documentation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
FILE: /Users/dohertj2/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs
|
|
||||||
LINE: 407
|
|
||||||
CATEGORY: TaskReferenceInComment
|
|
||||||
SEVERITY: Warning
|
|
||||||
MEMBER: Comment
|
|
||||||
SIGNATURE: non-403
|
|
||||||
MESSAGE: Comment contains 'non-403', which looks like a task/issue tracking identifier; tracking IDs should not appear in code documentation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
FILE: /Users/dohertj2/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcServer.cs
|
|
||||||
LINE: 43
|
|
||||||
CATEGORY: TaskReferenceInComment
|
|
||||||
SEVERITY: Warning
|
|
||||||
MEMBER: Comment
|
|
||||||
SIGNATURE: sub-100
|
|
||||||
MESSAGE: Comment contains 'sub-100', which looks like a task/issue tracking identifier; tracking IDs should not appear in code documentation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
FILE: /Users/dohertj2/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs
|
|
||||||
LINE: 29
|
|
||||||
CATEGORY: TaskReferenceInComment
|
|
||||||
SEVERITY: Warning
|
|
||||||
MEMBER: Comment
|
|
||||||
SIGNATURE: year-0001
|
|
||||||
MESSAGE: Comment contains 'year-0001', which looks like a task/issue tracking identifier; tracking IDs should not appear in code documentation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
FILE: /Users/dohertj2/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
|
|
||||||
LINE: 2034
|
|
||||||
CATEGORY: TrackingReferenceInComment
|
|
||||||
SEVERITY: Warning
|
|
||||||
MEMBER: Comment
|
|
||||||
SIGNATURE: M365
|
|
||||||
MESSAGE: Comment contains 'M365', which looks like a project tracking reference; bookkeeping references should not appear in code documentation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
FILE: /Users/dohertj2/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs
|
|
||||||
LINE: 206
|
|
||||||
CATEGORY: TrackingReferenceInComment
|
|
||||||
SEVERITY: Warning
|
|
||||||
MEMBER: Comment
|
|
||||||
SIGNATURE: M365
|
|
||||||
MESSAGE: Comment contains 'M365', which looks like a project tracking reference; bookkeeping references should not appear in code documentation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
FILE: /Users/dohertj2/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.NotificationService/MailKitSmtpClientWrapper.cs
|
|
||||||
LINE: 9
|
|
||||||
CATEGORY: TrackingReferenceInComment
|
|
||||||
SEVERITY: Warning
|
|
||||||
MEMBER: Comment
|
|
||||||
SIGNATURE: M365
|
|
||||||
MESSAGE: Comment contains 'M365', which looks like a project tracking reference; bookkeeping references should not appear in code documentation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
FILE: /Users/dohertj2/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.NotificationService/MailKitSmtpClientWrapper.cs
|
|
||||||
LINE: 113
|
|
||||||
CATEGORY: TrackingReferenceInComment
|
|
||||||
SEVERITY: Warning
|
|
||||||
MEMBER: Comment
|
|
||||||
SIGNATURE: M365
|
|
||||||
MESSAGE: Comment contains 'M365', which looks like a project tracking reference; bookkeeping references should not appear in code documentation.
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ Every plan follows the TDD bite-sized-task format and ships a co-located `.tasks
|
|||||||
|
|
||||||
| Plan | Domain | Tasks | Done | Status | Findings coverage |
|
| Plan | Domain | Tasks | Done | Status | Findings coverage |
|
||||||
|------|--------|------:|-----:|--------|-------------------|
|
|------|--------|------:|-----:|--------|-------------------|
|
||||||
| [PLAN-R2-01](PLAN-R2-01-cluster-host-failover.md) | Cluster, Host & Failover | 11 | 10 | ✅ Merged — T2 live-drill deferred | N1→T1–T4 (incl. live drill run + envelope measurement, covers R2-08's NF2); N2→T5–T7 (`needs-user`-adjacent: deploy overlay edits, no git add); N3→T8; N4→T9; N5→T10/T11; N6→R2-08 |
|
| [PLAN-R2-01](PLAN-R2-01-cluster-host-failover.md) | Cluster, Host & Failover | 11 | 11 | ✅ Merged — T2 RESOLVED 2026-08-01 (see note below) | N1→T1–T4 (incl. live drill run + envelope measurement, covers R2-08's NF2); N2→T5–T7 (`needs-user`-adjacent: deploy overlay edits, no git add); N3→T8; N4→T9; N5→T10/T11; N6→R2-08 |
|
||||||
| [PLAN-R2-02](PLAN-R2-02-communication-store-and-forward.md) | Communication & S&F + live alarm stream | 15 | 15 | ✅ Merged | **N1 Critical→T1–T4** (shared oldest-Up predicate, failing-first repro); **N2 High→T5–T7** (chunked resync protocol); N3→T8; N4→T9; N5→T6; N6→T10 (MUTEX w/ R2-07); N7→T11/T12; N8→T13/T14; N9→T15 |
|
| [PLAN-R2-02](PLAN-R2-02-communication-store-and-forward.md) | Communication & S&F + live alarm stream | 15 | 15 | ✅ Merged | **N1 Critical→T1–T4** (shared oldest-Up predicate, failing-first repro); **N2 High→T5–T7** (chunked resync protocol); N3→T8; N4→T9; N5→T6; N6→T10 (MUTEX w/ R2-07); N7→T11/T12; N8→T13/T14; N9→T15 |
|
||||||
| [PLAN-R2-03](PLAN-R2-03-site-runtime-dcl.md) | Site Runtime & DCL | 7 | 7 | ✅ Merged | N1→T1; N2→T2/T3; N3→T4; N4→T5/T6 (full compile-cache adoption); N5/N6→T7 |
|
| [PLAN-R2-03](PLAN-R2-03-site-runtime-dcl.md) | Site Runtime & DCL | 7 | 7 | ✅ Merged | N1→T1; N2→T2/T3; N3→T4; N4→T5/T6 (full compile-cache adoption); N5/N6→T7 |
|
||||||
| [PLAN-R2-04](PLAN-R2-04-data-audit-backbone.md) | Data & Audit Backbone + KPI rollups | 13 | 13 | ✅ Merged | **R1 High→T2–T4** (sliced backfill + watermark fast-path); R2→T1; R3→T5–T7; R4→T8; R5→T9; R6→T10/T11 (1 EF migration, build-first gotcha noted); R7→T12; final verify T13 |
|
| [PLAN-R2-04](PLAN-R2-04-data-audit-backbone.md) | Data & Audit Backbone + KPI rollups | 13 | 13 | ✅ Merged | **R1 High→T2–T4** (sliced backfill + watermark fast-path); R2→T1; R3→T5–T7; R4→T8; R5→T9; R6→T10/T11 (1 EF migration, build-first gotcha noted); R7→T12; final verify T13 |
|
||||||
@@ -27,6 +27,8 @@ Every plan follows the TDD bite-sized-task format and ships a co-located `.tasks
|
|||||||
|
|
||||||
**Round-2 progress: COMPLETE — all 8 plans executed (TDD, per-task commits) and merged to `main` @ `1930f19b` on 2026-07-13 (fast-forward of the `r2-integration` assembly; origin pushed; PRs #6–#13 closed). ~81 of 86 tasks landed; the remainder are human/environment-gated: R2-08 T1 (rotate wonder-app-vd03 API key), T2/T11/T13 (delete untracked live-credential + generated files), and R2-01 T2 (live docker failover drill — in-process envelope already measured at 33.7s). Integration surfaced + fixed 2 cross-plan regressions no per-project run caught: R2-08 NodeName validator vs IntegrationTests host-boot, and R2-03 compile-cache counter under parallel test load.**
|
**Round-2 progress: COMPLETE — all 8 plans executed (TDD, per-task commits) and merged to `main` @ `1930f19b` on 2026-07-13 (fast-forward of the `r2-integration` assembly; origin pushed; PRs #6–#13 closed). ~81 of 86 tasks landed; the remainder are human/environment-gated: R2-08 T1 (rotate wonder-app-vd03 API key), T2/T11/T13 (delete untracked live-credential + generated files), and R2-01 T2 (live docker failover drill — in-process envelope already measured at 33.7s). Integration surfaced + fixed 2 cross-plan regressions no per-project run caught: R2-08 NodeName validator vs IntegrationTests host-boot, and R2-03 compile-cache counter under parallel test load.**
|
||||||
|
|
||||||
|
> **Update 2026-08-07 (truth sweep):** the R2-01 T2 live failover drill is **RESOLVED as of 2026-08-01** — delivered via PLAN-R2-01 T4's live `FailoverTimingTests` on the real two-node in-process rig at production timings, plus `docker/failover-drill.sh` and `tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/SbrFailoverTests.cs` (`AutoDown_HardCrashOfOldestNode_*`) covering the oldest-crash direction. Recorded in the deferred-work register (`docs/plans/2026-07-08-deferred-work-register.md`, "Failover-timing measurement" row, RESOLVED 2026-08-01). R2-08 T11 (delete root `deferred.md`) and T13 (delete generated root docs reports) were completed by this sweep (the files were in fact tracked, so they were `git rm`'d). Remaining human-gated residuals: R2-08 T1 (key rotation) and T2 (delete credential files).
|
||||||
|
|
||||||
## Round-2 P0 (do first, any order)
|
## Round-2 P0 (do first, any order)
|
||||||
|
|
||||||
1. **R2-08 T1/T2** — rotate the exposed wonder-app-vd03 API key; delete `test.txt` + the three root credential files (ALL contain live secrets incl. a production sysadmin password). `needs-user`.
|
1. **R2-08 T1/T2** — rotate the exposed wonder-app-vd03 API key; delete `test.txt` + the three root credential files (ALL contain live secrets incl. a production sysadmin password). `needs-user`.
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"subject": "Task 1: Rewrite failover-drill.sh — standby-victim default + explicit active-victim gap mode",
|
"subject": "Task 1: Rewrite failover-drill.sh — standby-victim default + explicit active-victim gap mode",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"subject": "Task 2: Correct the recovery narrative + document the first-seed bootstrap constraint",
|
"subject": "Task 2: Correct the recovery narrative + document the first-seed bootstrap constraint",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
1
|
1
|
||||||
]
|
]
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
{
|
{
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"subject": "Task 3: RUN the drill live (both directions) and record measured timings",
|
"subject": "Task 3: RUN the drill live (both directions) and record measured timings",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
|
"notes": "The live-drill residual (tracker: 'T2 live-drill deferred') was RESOLVED 2026-08-01 via PLAN-R2-01 T4's live FailoverTimingTests + docker/failover-drill.sh + SbrFailoverTests.AutoDown_HardCrashOfOldestNode_*; recorded in the deferred-work register. Statuses in this manifest flipped from stale 'pending' per 00-MASTER-TRACKER.md by the 2026-08-07 truth sweep.",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
1,
|
1,
|
||||||
2
|
2
|
||||||
@@ -27,25 +28,25 @@
|
|||||||
{
|
{
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"subject": "Task 4: Wire FailoverTimingTests to TwoNodeClusterFixture — measure the ~25s envelope in-process",
|
"subject": "Task 4: Wire FailoverTimingTests to TwoNodeClusterFixture — measure the ~25s envelope in-process",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"subject": "Task 5: Apply the wonder-app-vd03 appsettings overlay edits (NodeName + AllowSingleNodeCluster, drop phantom seeds)",
|
"subject": "Task 5: Apply the wonder-app-vd03 appsettings overlay edits (NodeName + AllowSingleNodeCluster, drop phantom seeds)",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 6,
|
"id": 6,
|
||||||
"subject": "Task 6: Complete the install.ps1 recovery actions (sc.exe failureflag) + RUNBOOK recovery step",
|
"subject": "Task 6: Complete the install.ps1 recovery actions (sc.exe failureflag) + RUNBOOK recovery step",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"subject": "Task 7: Correct the factually wrong N2 deferral record in the master tracker",
|
"subject": "Task 7: Correct the factually wrong N2 deferral record in the master tracker",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
5,
|
5,
|
||||||
6
|
6
|
||||||
@@ -54,29 +55,29 @@
|
|||||||
{
|
{
|
||||||
"id": 8,
|
"id": 8,
|
||||||
"subject": "Task 8: Metrics-staleness for never-reported sites (FirstSeenAt anchor)",
|
"subject": "Task 8: Metrics-staleness for never-reported sites (FirstSeenAt anchor)",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 9,
|
"id": 9,
|
||||||
"subject": "Task 9: Purge the stale \"cluster leader\" narration from CentralHealthReportLoop",
|
"subject": "Task 9: Purge the stale \"cluster leader\" narration from CentralHealthReportLoop",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 10,
|
"id": 10,
|
||||||
"subject": "Task 10: Generalize the registrar — SingletonRegistrar with an optional role scope",
|
"subject": "Task 10: Generalize the registrar — SingletonRegistrar with an optional role scope",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 11,
|
"id": 11,
|
||||||
"subject": "Task 11: Route the two site singletons through the registrar — deployment-manager + event-log-handler gain drains",
|
"subject": "Task 11: Route the two site singletons through the registrar — deployment-manager + event-log-handler gain drains",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
10
|
10
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-07-13T03:25:44Z"
|
"lastUpdated": "2026-08-07T00:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,19 @@
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"subject": "Task 1: Shared oldest-Up active-node evaluator, reachable from Communication and SiteRuntime",
|
"subject": "Task 1: Shared oldest-Up active-node evaluator, reachable from Communication and SiteRuntime",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"subject": "Task 2: Two-node divergence repro — leader≠oldest wipes the delivering node's buffer (failing first)",
|
"subject": "Task 2: Two-node divergence repro — leader≠oldest wipes the delivering node's buffer (failing first)",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"subject": "Task 3: Swap SiteReplicationActor to the shared oldest-Up predicate + Host wires the delivery-gate delegate",
|
"subject": "Task 3: Swap SiteReplicationActor to the shared oldest-Up predicate + Host wires the delivery-gate delegate",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
1,
|
1,
|
||||||
2
|
2
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
{
|
{
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"subject": "Task 4: Swap SiteCommunicationActor.DefaultIsActiveCheck + Host wiring + doc sync",
|
"subject": "Task 4: Swap SiteCommunicationActor.DefaultIsActiveCheck + Host wiring + doc sync",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
3
|
3
|
||||||
]
|
]
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
{
|
{
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"subject": "Task 5: Chunked resync protocol — additive messages, byte-budgeted chunker, active-side chunked answer",
|
"subject": "Task 5: Chunked resync protocol — additive messages, byte-budgeted chunker, active-side chunked answer",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
3
|
3
|
||||||
]
|
]
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
{
|
{
|
||||||
"id": 6,
|
"id": 6,
|
||||||
"subject": "Task 6: Standby-side chunk assembly, atomic apply, ack + the N5 race comment",
|
"subject": "Task 6: Standby-side chunk assembly, atomic apply, ack + the N5 race comment",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
5
|
5
|
||||||
]
|
]
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
{
|
{
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"subject": "Task 7: Resync delivery confirmation on the active node + telemetry + doc rewrite",
|
"subject": "Task 7: Resync delivery confirmation on the active node + telemetry + doc rewrite",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
6
|
6
|
||||||
]
|
]
|
||||||
@@ -57,25 +57,25 @@
|
|||||||
{
|
{
|
||||||
"id": 8,
|
"id": 8,
|
||||||
"subject": "Task 8: Publish _sweepTask only when the sweep CAS is won (unclobber the shutdown drain)",
|
"subject": "Task 8: Publish _sweepTask only when the sweep CAS is won (unclobber the shutdown drain)",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 9,
|
"id": 9,
|
||||||
"subject": "Task 9: Eager validation for SweepBatchLimit / SweepTargetParallelism",
|
"subject": "Task 9: Eager validation for SweepBatchLimit / SweepTargetParallelism",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 10,
|
"id": 10,
|
||||||
"subject": "Task 10: Coalesce live-alarm delta publishes (bound the per-circuit fan-out) [MUTEX with PLAN-R2-07]",
|
"subject": "Task 10: Coalesce live-alarm delta publishes (bound the per-circuit fan-out) [MUTEX with PLAN-R2-07]",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 11,
|
"id": 11,
|
||||||
"subject": "Task 11: Aggregator lifecycle — queued re-seed after reconnect + stream-generation stamp",
|
"subject": "Task 11: Aggregator lifecycle — queued re-seed after reconnect + stream-generation stamp",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
10
|
10
|
||||||
]
|
]
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
{
|
{
|
||||||
"id": 12,
|
"id": 12,
|
||||||
"subject": "Task 12: Instance-injected ReconnectDelay/StabilityWindow (kill the process-global test seams)",
|
"subject": "Task 12: Instance-injected ReconnectDelay/StabilityWindow (kill the process-global test seams)",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
11
|
11
|
||||||
]
|
]
|
||||||
@@ -91,13 +91,13 @@
|
|||||||
{
|
{
|
||||||
"id": 13,
|
"id": 13,
|
||||||
"subject": "Task 13: Production caller for RemoveSiteAsync — dispose a deleted site's gRPC channels",
|
"subject": "Task 13: Production caller for RemoveSiteAsync — dispose a deleted site's gRPC channels",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 14,
|
"id": 14,
|
||||||
"subject": "Task 14: Documented acceptance — standby-node aggregators + deleted-site viewer behavior",
|
"subject": "Task 14: Documented acceptance — standby-node aggregators + deleted-site viewer behavior",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
4
|
4
|
||||||
]
|
]
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
{
|
{
|
||||||
"id": 15,
|
"id": 15,
|
||||||
"subject": "Task 15: Single-endpoint sites can go live [MUTEX with PLAN-R2-07]",
|
"subject": "Task 15: Single-endpoint sites can go live [MUTEX with PLAN-R2-07]",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
12
|
12
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"planPath": "archreview/plans/PLAN-R2-03-site-runtime-dcl.md",
|
"planPath": "archreview/plans/PLAN-R2-03-site-runtime-dcl.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{ "id": 1, "subject": "Task 1: MxGatewayDataConnection — stale event-loop fault on a cancelled token must not signal Disconnected (N1)", "status": "pending", "blockedBy": [] },
|
{ "id": 1, "subject": "Task 1: MxGatewayDataConnection — stale event-loop fault on a cancelled token must not signal Disconnected (N1)", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 2, "subject": "Task 2: ScriptActor — failure mapping on the expression-eval PipeTo clears _evalInFlight (N2)", "status": "pending", "blockedBy": [] },
|
{ "id": 2, "subject": "Task 2: ScriptActor — failure mapping on the expression-eval PipeTo clears _evalInFlight (N2)", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 3, "subject": "Task 3: AlarmActor — failure mapping on the expression-eval PipeTo clears _evalInFlight (N2)", "status": "pending", "blockedBy": [] },
|
{ "id": 3, "subject": "Task 3: AlarmActor — failure mapping on the expression-eval PipeTo clears _evalInFlight (N2)", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 4, "subject": "Task 4: Eagerly validate TagSubscribeRetryIntervalMs + StuckScriptGraceMs (N3)", "status": "pending", "blockedBy": [] },
|
{ "id": 4, "subject": "Task 4: Eagerly validate TagSubscribeRetryIntervalMs + StuckScriptGraceMs (N3)", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 5, "subject": "Task 5: SiteScriptCompileCache — bounded process-wide compiled-script cache (N4 groundwork)", "status": "pending", "blockedBy": [] },
|
{ "id": 5, "subject": "Task 5: SiteScriptCompileCache — bounded process-wide compiled-script cache (N4 groundwork)", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 6, "subject": "Task 6: Wire the compile cache into ScriptCompilationService — deploy gate + PreStart share one compile (N4)", "status": "pending", "blockedBy": [5] },
|
{ "id": 6, "subject": "Task 6: Wire the compile cache into ScriptCompilationService — deploy gate + PreStart share one compile (N4)", "status": "completed", "blockedBy": [5] },
|
||||||
{ "id": 7, "subject": "Task 7: Design-doc sync — synchronous compile gate (N5), Expression/scheduler coupling (N6), P6/N4 note refresh", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6] }
|
{ "id": 7, "subject": "Task 7: Design-doc sync — synchronous compile gate (N5), Expression/scheduler coupling (N6), P6/N4 note refresh", "status": "completed", "blockedBy": [1, 2, 3, 4, 5, 6] }
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-07-13T03:23:02Z"
|
"lastUpdated": "2026-07-13T03:23:02Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"planPath": "archreview/plans/PLAN-R2-04-data-audit-backbone.md",
|
"planPath": "archreview/plans/PLAN-R2-04-data-audit-backbone.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{ "id": 1, "subject": "Task 1: Untracked, projected fold fetch (R2)", "status": "pending", "blockedBy": [] },
|
{ "id": 1, "subject": "Task 1: Untracked, projected fold fetch (R2)", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 2, "subject": "Task 2: Rollup watermark seam — GetLatestRollupHourAsync (R1, part 1)", "status": "pending", "blockedBy": [1] },
|
{ "id": 2, "subject": "Task 2: Rollup watermark seam — GetLatestRollupHourAsync (R1, part 1)", "status": "completed", "blockedBy": [1] },
|
||||||
{ "id": 3, "subject": "Task 3: Slice the rollup backfill into bounded day windows (R1, part 2)", "status": "pending", "blockedBy": [2] },
|
{ "id": 3, "subject": "Task 3: Slice the rollup backfill into bounded day windows (R1, part 2)", "status": "completed", "blockedBy": [2] },
|
||||||
{ "id": 4, "subject": "Task 4: Backfill failover fast-path via the rollup watermark + doc correction (R1, part 3)", "status": "pending", "blockedBy": [2, 3] },
|
{ "id": 4, "subject": "Task 4: Backfill failover fast-path via the rollup watermark + doc correction (R1, part 3)", "status": "completed", "blockedBy": [2, 3] },
|
||||||
{ "id": 5, "subject": "Task 5: Per-metric reduction in the bucketer — sum-per-bucket for Rate series (R3, part 1)", "status": "pending", "blockedBy": [] },
|
{ "id": 5, "subject": "Task 5: Per-metric reduction in the bucketer — sum-per-bucket for Rate series (R3, part 1)", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 6, "subject": "Task 6: Catalog-driven aggregation at the query-service boundary (R3, part 2)", "status": "pending", "blockedBy": [5] },
|
{ "id": 6, "subject": "Task 6: Catalog-driven aggregation at the query-service boundary (R3, part 2)", "status": "completed", "blockedBy": [5] },
|
||||||
{ "id": 7, "subject": "Task 7: Truthful trend presentation — chart doc + Component-KpiHistory.md (R3, part 3)", "status": "pending", "blockedBy": [6] },
|
{ "id": 7, "subject": "Task 7: Truthful trend presentation — chart doc + Component-KpiHistory.md (R3, part 3)", "status": "completed", "blockedBy": [6] },
|
||||||
{ "id": 8, "subject": "Task 8: Close the catalog metric-literal drift hazard (R4)", "status": "pending", "blockedBy": [] },
|
{ "id": 8, "subject": "Task 8: Close the catalog metric-literal drift hazard (R4)", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 9, "subject": "Task 9: Classify the failover fold-race failure grain (R5)", "status": "pending", "blockedBy": [1, 2] },
|
{ "id": 9, "subject": "Task 9: Classify the failover fold-race failure grain (R5)", "status": "completed", "blockedBy": [1, 2] },
|
||||||
{ "id": 10, "subject": "Task 10: SiteCalls filtered terminal index (migration) (R6, part 1)", "status": "pending", "blockedBy": [] },
|
{ "id": 10, "subject": "Task 10: SiteCalls filtered terminal index (migration) (R6, part 1)", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 11, "subject": "Task 11: Time-sliced SiteCalls terminal purge (R6, part 2)", "status": "pending", "blockedBy": [10] },
|
{ "id": 11, "subject": "Task 11: Time-sliced SiteCalls terminal purge (R6, part 2)", "status": "completed", "blockedBy": [10] },
|
||||||
{ "id": 12, "subject": "Task 12: Retention-service shutdown no longer surfaces cancellation (R7)", "status": "pending", "blockedBy": [] },
|
{ "id": 12, "subject": "Task 12: Retention-service shutdown no longer surfaces cancellation (R7)", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 13, "subject": "Final verification: solution build + targeted suites this plan touched (infra up for MSSQL SkippableFacts)", "status": "pending", "blockedBy": [4, 7, 8, 9, 11, 12] }
|
{ "id": 13, "subject": "Final verification: solution build + targeted suites this plan touched (infra up for MSSQL SkippableFacts)", "status": "completed", "blockedBy": [4, 7, 8, 9, 11, 12] }
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-07-13T03:25:01Z"
|
"lastUpdated": "2026-07-13T03:25:01Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"planPath": "archreview/plans/PLAN-R2-05-templates-deployment-transport.md",
|
"planPath": "archreview/plans/PLAN-R2-05-templates-deployment-transport.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{ "id": 1, "subject": "Task 1: Full violation/error lists in the trigger-expression syntax check (N2)", "status": "pending", "blockedBy": [] },
|
{ "id": 1, "subject": "Task 1: Full violation/error lists in the trigger-expression syntax check (N2)", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 2, "subject": "Task 2: Add a globals-surface discriminator to the verdict-cache key (N1 part 1)", "status": "pending", "blockedBy": [1] },
|
{ "id": 2, "subject": "Task 2: Add a globals-surface discriminator to the verdict-cache key (N1 part 1)", "status": "completed", "blockedBy": [1] },
|
||||||
{ "id": 3, "subject": "Task 3: Cache Expression-trigger verdicts under the trigger surface key (N1 part 2)", "status": "pending", "blockedBy": [2] },
|
{ "id": 3, "subject": "Task 3: Cache Expression-trigger verdicts under the trigger surface key (N1 part 2)", "status": "completed", "blockedBy": [2] },
|
||||||
{ "id": 4, "subject": "Task 4: Skip the trigger syntax check on read-only staleness/comparison paths (N1 part 3)", "status": "pending", "blockedBy": [3] },
|
{ "id": 4, "subject": "Task 4: Skip the trigger syntax check on read-only staleness/comparison paths (N1 part 3)", "status": "completed", "blockedBy": [3] },
|
||||||
{ "id": 5, "subject": "Task 5: Publish ScriptArtifactsChanged for Add resolutions too (N3)", "status": "pending", "blockedBy": [] },
|
{ "id": 5, "subject": "Task 5: Publish ScriptArtifactsChanged for Add resolutions too (N3)", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 6, "subject": "Task 6: Trust-gate instance alarm-override trigger expressions (N5)", "status": "pending", "blockedBy": [5] },
|
{ "id": 6, "subject": "Task 6: Trust-gate instance alarm-override trigger expressions (N5)", "status": "completed", "blockedBy": [5] },
|
||||||
{ "id": 7, "subject": "Task 7: Warn when import persists overrides on locked template members (N4)", "status": "pending", "blockedBy": [6] },
|
{ "id": 7, "subject": "Task 7: Warn when import persists overrides on locked template members (N4)", "status": "completed", "blockedBy": [6] },
|
||||||
{ "id": 8, "subject": "Task 8: Validate MaxConcurrentImportSessions at startup (N6)", "status": "pending", "blockedBy": [] },
|
{ "id": 8, "subject": "Task 8: Validate MaxConcurrentImportSessions at startup (N6)", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 9, "subject": "Task 9: Design-doc sync sweep", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8] }
|
{ "id": 9, "subject": "Task 9: Design-doc sync sweep", "status": "completed", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8] }
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-07-13T03:23:07Z"
|
"lastUpdated": "2026-07-13T03:23:07Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"planPath": "archreview/plans/PLAN-R2-06-edge-integrations.md",
|
"planPath": "archreview/plans/PLAN-R2-06-edge-integrations.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{ "id": 1, "subject": "Task 1: ScriptArtifactChangeSubscriber — wire the Inbound API as the bus's ApiMethod consumer", "status": "pending", "blockedBy": [] },
|
{ "id": 1, "subject": "Task 1: ScriptArtifactChangeSubscriber — wire the Inbound API as the bus's ApiMethod consumer", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 2, "subject": "Task 2: Truth sweep — tracker / contract-doc / CLAUDE.md claims about the bus consumer", "status": "pending", "blockedBy": [1] },
|
{ "id": 2, "subject": "Task 2: Truth sweep — tracker / contract-doc / CLAUDE.md claims about the bus consumer", "status": "completed", "blockedBy": [1] },
|
||||||
{ "id": 3, "subject": "Task 3: DeliverBufferedAsync parks deterministic ArgumentException failures (path template / verb)", "status": "pending", "blockedBy": [] },
|
{ "id": 3, "subject": "Task 3: DeliverBufferedAsync parks deterministic ArgumentException failures (path template / verb)", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 4, "subject": "Task 4: Honor the declared response charset in ReadBodyBoundedAsync", "status": "pending", "blockedBy": [3] },
|
{ "id": 4, "subject": "Task 4: Honor the declared response charset in ReadBodyBoundedAsync", "status": "completed", "blockedBy": [3] },
|
||||||
{ "id": 5, "subject": "Task 5: Typed SITE_UNREACHABLE — AskTimeoutException classification replaces message sniffing", "status": "pending", "blockedBy": [] },
|
{ "id": 5, "subject": "Task 5: Typed SITE_UNREACHABLE — AskTimeoutException classification replaces message sniffing", "status": "completed", "blockedBy": [] },
|
||||||
{ "id": 6, "subject": "Task 6: 415 guard covers chunked (no Content-Length) bodies", "status": "pending", "blockedBy": [] }
|
{ "id": 6, "subject": "Task 6: 415 guard covers chunked (no Content-Length) bodies", "status": "completed", "blockedBy": [] }
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-07-13T03:23:07Z"
|
"lastUpdated": "2026-07-13T03:23:07Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,19 @@
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"subject": "Task 1: Route the DebugStreamHub LDAP bind through ManagementAuthenticator (throttled) + fix the false doc claim",
|
"subject": "Task 1: Route the DebugStreamHub LDAP bind through ManagementAuthenticator (throttled) + fix the false doc claim",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"subject": "Task 2: ForwardedHeadersSetup — trusted-proxy client-IP resolution for the throttle keys",
|
"subject": "Task 2: ForwardedHeadersSetup — trusted-proxy client-IP resolution for the throttle keys",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"subject": "Task 3: Ship the ForwardedHeaders config to the Traefik topologies + document the lockout-DoS trade-off",
|
"subject": "Task 3: Ship the ForwardedHeaders config to the Traefik topologies + document the lockout-DoS trade-off",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
2
|
2
|
||||||
]
|
]
|
||||||
@@ -24,19 +24,19 @@
|
|||||||
{
|
{
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"subject": "Task 4: Enforce site scope on secured-write submit / approve / reject",
|
"subject": "Task 4: Enforce site scope on secured-write submit / approve / reject",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"subject": "Task 5: ISecuredWriteRepository — additive permitted-sites filter for scoped listing",
|
"subject": "Task 5: ISecuredWriteRepository — additive permitted-sites filter for scoped listing",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 6,
|
"id": 6,
|
||||||
"subject": "Task 6: Scope-filter HandleListSecuredWrites + amend Component-Security.md:141 + matrix verification",
|
"subject": "Task 6: Scope-filter HandleListSecuredWrites + amend Component-Security.md:141 + matrix verification",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
4,
|
4,
|
||||||
5
|
5
|
||||||
@@ -45,13 +45,13 @@
|
|||||||
{
|
{
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"subject": "Task 7: AlarmSummary.RefreshAsync stale-site guard — never display cross-site data",
|
"subject": "Task 7: AlarmSummary.RefreshAsync stale-site guard — never display cross-site data",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 8,
|
"id": 8,
|
||||||
"subject": "Task 8: While live, the poll updates only _notReporting — never regresses live rows",
|
"subject": "Task 8: While live, the poll updates only _notReporting — never regresses live rows",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
7
|
7
|
||||||
]
|
]
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
{
|
{
|
||||||
"id": 9,
|
"id": 9,
|
||||||
"subject": "Task 9: House disposal guard on the live callback",
|
"subject": "Task 9: House disposal guard on the live callback",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
8
|
8
|
||||||
]
|
]
|
||||||
@@ -67,19 +67,19 @@
|
|||||||
{
|
{
|
||||||
"id": 10,
|
"id": 10,
|
||||||
"subject": "Task 10: Un-stick IsLive — deathwatch resets liveness when the aggregator terminates (MUTEX with PLAN-R2-02 SiteAlarmLiveCacheService task)",
|
"subject": "Task 10: Un-stick IsLive — deathwatch resets liveness when the aggregator terminates (MUTEX with PLAN-R2-02 SiteAlarmLiveCacheService task)",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 11,
|
"id": 11,
|
||||||
"subject": "Task 11: Amortize LoginThrottle.Prune off the failure hot path",
|
"subject": "Task 11: Amortize LoginThrottle.Prune off the failure hot path",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 12,
|
"id": 12,
|
||||||
"subject": "Task 12: Lock in the scrubber's fragment coverage + document the array-merge limitation",
|
"subject": "Task 12: Lock in the scrubber's fragment coverage + document the array-merge limitation",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,80 +5,84 @@
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"subject": "Task 1: Rotate the exposed wonder-app-vd03 Inbound API key (dual-key flow) — needs-user",
|
"subject": "Task 1: Rotate the exposed wonder-app-vd03 Inbound API key (dual-key flow) — needs-user",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"blockedBy": []
|
"blockedBy": [],
|
||||||
|
"notes": "Still open per 00-MASTER-TRACKER.md (human-gated residual; needs-user)."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"subject": "Task 2: Delete test.txt and triage the root credential files — needs-user",
|
"subject": "Task 2: Delete test.txt and triage the root credential files — needs-user",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"blockedBy": [1]
|
"blockedBy": [1],
|
||||||
|
"notes": "Still open per 00-MASTER-TRACKER.md (human-gated residual; needs-user)."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"subject": "Task 3: Root secret-capture guards — .gitignore patterns + pre-commit secret scan",
|
"subject": "Task 3: Root secret-capture guards — .gitignore patterns + pre-commit secret scan",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"subject": "Task 4: CHANGELOG.md — correct the false role claim and refresh to reality",
|
"subject": "Task 4: CHANGELOG.md — correct the false role claim and refresh to reality",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"subject": "Task 5: Options validation — AuditLog site sub-options (SiteWriter, SiteTelemetry, SiteRetention)",
|
"subject": "Task 5: Options validation — AuditLog site sub-options (SiteWriter, SiteTelemetry, SiteRetention)",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 6,
|
"id": 6,
|
||||||
"subject": "Task 6: Options validation — AuditLog central sub-options (PartitionMaintenance, Purge, Reconciliation)",
|
"subject": "Task 6: Options validation — AuditLog central sub-options (PartitionMaintenance, Purge, Reconciliation)",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [5]
|
"blockedBy": [5]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"subject": "Task 7: Options validation — Host NodeOptions / DatabaseOptions / LoggingOptions (empty NodeName fails fast)",
|
"subject": "Task 7: Options validation — Host NodeOptions / DatabaseOptions / LoggingOptions (empty NodeName fails fast)",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 8,
|
"id": 8,
|
||||||
"subject": "Task 8: OperationTrackingOptions binding + the missing site IOperationTrackingStore registration (verify-then-fix)",
|
"subject": "Task 8: OperationTrackingOptions binding + the missing site IOperationTrackingStore registration (verify-then-fix)",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [7]
|
"blockedBy": [7]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 9,
|
"id": 9,
|
||||||
"subject": "Task 9: NF8 — canonicalize the Communication/DataConnection section names, drop the duplicate Host bindings",
|
"subject": "Task 9: NF8 — canonicalize the Communication/DataConnection section names, drop the duplicate Host bindings",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [8]
|
"blockedBy": [8]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 10,
|
"id": 10,
|
||||||
"subject": "Task 10: Re-consolidate deferral tracking into the canonical register",
|
"subject": "Task 10: Re-consolidate deferral tracking into the canonical register",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 11,
|
"id": 11,
|
||||||
"subject": "Task 11: Delete the drifted root deferred.md snapshot — needs-user",
|
"subject": "Task 11: Delete the drifted root deferred.md snapshot — needs-user",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [10]
|
"blockedBy": [10],
|
||||||
|
"notes": "Completed 2026-08-07 (truth sweep): deferred.md was in fact TRACKED (the plan's 'untracked' premise was wrong), so it was removed via git rm."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 12,
|
"id": 12,
|
||||||
"subject": "Task 12: Fix the stale Transport area comment in EntitySerializer.FromBundleContent",
|
"subject": "Task 12: Fix the stale Transport area comment in EntitySerializer.FromBundleContent",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 13,
|
"id": 13,
|
||||||
"subject": "Task 13: Commit the orphaned MES plan doc; remove the generated root reports — needs-user (deletions only)",
|
"subject": "Task 13: Commit the orphaned MES plan doc; remove the generated root reports — needs-user (deletions only)",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": [],
|
||||||
|
"notes": "Completed 2026-08-07 (truth sweep): ScadaBridge-docs-issues.md + ScadaBridge-docs-fixed.md were in fact TRACKED and removed via git rm; the MES plan doc was already committed earlier (3c9b101d). Other tasks in this manifest flipped to completed per 00-MASTER-TRACKER.md (round 2 merged to main @ 1930f19b, 2026-07-13)."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-07-13T03:30:22Z"
|
"lastUpdated": "2026-08-07T00:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
-44
@@ -1,44 +0,0 @@
|
|||||||
# Remaining Deferred Work
|
|
||||||
|
|
||||||
Source: `docs/plans/2026-07-08-deferred-work-register.md` (snapshot 2026-07-10).
|
|
||||||
Everything in the register's "Fix-now" table is already landed via the archreview
|
|
||||||
plans; what's left are the intentional deferrals below.
|
|
||||||
|
|
||||||
## Product / roadmap-locked (revisit needs a decision or a trigger event)
|
|
||||||
|
|
||||||
| # | Item | Why deferred | Revisit trigger |
|
|
||||||
|---|------|--------------|-----------------|
|
|
||||||
| 8 | Hash-chain tamper evidence (CLI `verify-chain` is a no-op stub) | v1.x by locked decision — append-only DB roles are the current control | A compliance requirement for cryptographic tamper evidence |
|
|
||||||
| 9 | Parquet audit archival (endpoint returns `501`) | v1.x — the `501` + CLI messaging are honest, not broken | AuditLog partition volume nears the retention ceiling |
|
|
||||||
| 11 | Central-persisted OPC UA cert-trust audit | Broadcast-to-both-nodes already covers HA | A governance/audit requirement for trust decisions |
|
|
||||||
| 19 | Bundle signing / cluster-to-cluster pull / differential bundles | v1 manifest hash + AES-GCM held sufficient | A non-repudiation requirement across orgs |
|
|
||||||
| 17 | Unified notifications + site-calls outbox page | Explicit M9 decision to keep two pages | Operator confusion reports |
|
|
||||||
| 18 | Folder drag-drop | Permanently closed — menu reorder shipped instead | — (closed) |
|
|
||||||
|
|
||||||
## Scale / YAGNI (deferred until load justifies it)
|
|
||||||
|
|
||||||
| # | Item | Why deferred | Revisit trigger | Status |
|
|
||||||
|---|------|--------------|-----------------|--------|
|
|
||||||
| 10 | Aggregated live alarm stream for Alarm Summary | Snapshot fan-out is acceptable at current instance counts | Latency complaints or >~50 instances/site | ✅ **SHIPPED + MERGED to main 2026-07-10** (`8c888f13`, plan `docs/plans/2026-07-10-aggregated-live-alarm-stream-plan.md`, T1–T8). Transient per-site in-memory live cache (`ISiteAlarmLiveCache`/`SiteAlarmAggregatorActor`) seeded by snapshot fan-out + additive `SubscribeSite` alarm-only gRPC stream; live-cache-driven Alarm Summary with 15s poll fallback; `[PERM]` no-central-store honored (code-reviewer-confirmed); validated options + telemetry; end-to-end trace. Register row moved to Resolved. |
|
|
||||||
| 22 | KPI history hourly rollups | 90-day retention already bounds the table | `KpiSample` query latency on dashboards | ✅ **SHIPPED + MERGED to main 2026-07-10** (`8c888f13`, plan `docs/plans/2026-07-10-kpi-history-hourly-rollups-plan.md`, T1–T8). Separate `KpiRollupHourly` table (migration `20260710153953`), recorder hourly fold w/ failover-safe lookback re-fold + idempotent upsert, per-metric gauge-vs-rate aggregation, range-threshold query routing (`RollupThresholdHours` 168h), longer rollup retention (365 ≥ 90) + dual purge, one-shot backfill, and 30 d/90 d window buttons. Register row moved to Resolved. |
|
|
||||||
|
|
||||||
## Low-priority polish (near-complete, small remainder)
|
|
||||||
|
|
||||||
| # | Item | Why deferred | Revisit trigger |
|
|
||||||
|---|------|--------------|-----------------|
|
|
||||||
| 12 | Native-alarm-source-override CSV import — Central UI upload button only | CLI + Management API + parser shipped 2026-07-10; the Blazor upload affordance is the only piece left, and it's pure polish (needs a live Blazor smoke) | First request to bulk-import native sources from the UI instead of the CLI |
|
|
||||||
|
|
||||||
## New deferrals from review 08 (engineering debt, no defect)
|
|
||||||
|
|
||||||
| Item | Why deferred | Revisit trigger |
|
|
||||||
|------|--------------|-----------------|
|
|
||||||
| Communication → HealthMonitoring layering inversion | Moving the interface + `SiteHealthState` to Commons ripples across 5 projects for a cosmetic inversion | Next breaking change to `ICentralHealthAggregator` |
|
|
||||||
| Reference docs for ScriptAnalysis, KpiHistory, DelmiaNotifier | Full StyleGuide-conformant docs are substantial; README claim was scoped instead (PLAN-08 T10) | Next doc-writing session touching those components |
|
|
||||||
| Test-coverage backfill: SiteCallAudit.Tests, DeploymentManager.Tests | No defect identified; coverage partly lives in ManagementService/Host/Integration suites | First regression escaping either component |
|
|
||||||
| Failover-timing + broader perf envelope (S&F drain rate, per-subscriber backpressure) | Needs the PLAN-01 two-node rig; placeholder harness already shipped (PLAN-08 T8) | PLAN-01 rig landing |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Summary: 12 open deferrals (13th, folder drag-drop #18, is permanently closed).
|
|
||||||
None are currently actionable without a triggering event or product decision —
|
|
||||||
except row #12's UI upload button, whose CLI/API/parser core already shipped.
|
|
||||||
@@ -47,9 +47,9 @@
|
|||||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||||
// refuse every SiteStream call while the node still looked healthy.
|
// refuse every SiteStream call while the node still looked healthy.
|
||||||
"GrpcPsk": "dev-grpc-psk-docker-env2-site-x",
|
"GrpcPsk": "dev-grpc-psk-docker-env2-site-x",
|
||||||
"CentralContactPoints": [
|
"CentralGrpcEndpoints": [
|
||||||
"akka.tcp://scadabridge@scadabridge-env2-central-a:8081",
|
"http://scadabridge-env2-central-a:8083",
|
||||||
"akka.tcp://scadabridge@scadabridge-env2-central-b:8081"
|
"http://scadabridge-env2-central-b:8083"
|
||||||
],
|
],
|
||||||
"DeploymentTimeout": "00:02:00",
|
"DeploymentTimeout": "00:02:00",
|
||||||
"LifecycleTimeout": "00:00:30",
|
"LifecycleTimeout": "00:00:30",
|
||||||
|
|||||||
@@ -47,9 +47,9 @@
|
|||||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||||
// refuse every SiteStream call while the node still looked healthy.
|
// refuse every SiteStream call while the node still looked healthy.
|
||||||
"GrpcPsk": "dev-grpc-psk-docker-env2-site-x",
|
"GrpcPsk": "dev-grpc-psk-docker-env2-site-x",
|
||||||
"CentralContactPoints": [
|
"CentralGrpcEndpoints": [
|
||||||
"akka.tcp://scadabridge@scadabridge-env2-central-a:8081",
|
"http://scadabridge-env2-central-a:8083",
|
||||||
"akka.tcp://scadabridge@scadabridge-env2-central-b:8081"
|
"http://scadabridge-env2-central-b:8083"
|
||||||
],
|
],
|
||||||
"DeploymentTimeout": "00:02:00",
|
"DeploymentTimeout": "00:02:00",
|
||||||
"LifecycleTimeout": "00:00:30",
|
"LifecycleTimeout": "00:00:30",
|
||||||
|
|||||||
@@ -15,7 +15,14 @@
|
|||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
"MinNrOfMembers": 1
|
"MinNrOfMembers": 1,
|
||||||
|
"_bootstrapGuard": "Gitea #33 guard ENABLED on the docker rig (2026-08-02): deploy.sh recreates all containers simultaneously, which twice split site pairs into two 1-node clusters on 2026-08-01. Lower host:port founds self-first; the higher node TCP-probes then joins peer-first.",
|
||||||
|
"BootstrapGuard": {
|
||||||
|
"Enabled": true,
|
||||||
|
"PartnerProbeSeconds": 25,
|
||||||
|
"PartnerProbeIntervalMs": 500,
|
||||||
|
"ProbeConnectTimeoutMs": 1000
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
"Database": {
|
||||||
"_comment": "ConfigurationDb/MachineDataDb (which carry the dev SQL password) are supplied as ScadaBridge__Database__* whole-key env overrides in docker/docker-compose.yml (dev-only-insecure, mirroring the ApiKeyPepper convention). The same dev password already ships committed for the sibling scadabridge-mssql container (infra/docker-compose.yml MSSQL_SA_PASSWORD) — this is consolidation, not new exposure. Env overrides layer over JSON before the ${secret:} expander runs, so the dev cluster boots with no KEK/secret store. Real/prod config uses ${secret:} tokens in src/.../appsettings.Central.json (T4)."
|
"_comment": "ConfigurationDb/MachineDataDb (which carry the dev SQL password) are supplied as ScadaBridge__Database__* whole-key env overrides in docker/docker-compose.yml (dev-only-insecure, mirroring the ApiKeyPepper convention). The same dev password already ships committed for the sibling scadabridge-mssql container (infra/docker-compose.yml MSSQL_SA_PASSWORD) — this is consolidation, not new exposure. Env overrides layer over JSON before the ${secret:} expander runs, so the dev cluster boots with no KEK/secret store. Real/prod config uses ${secret:} tokens in src/.../appsettings.Central.json (T4)."
|
||||||
|
|||||||
@@ -15,7 +15,14 @@
|
|||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
"MinNrOfMembers": 1
|
"MinNrOfMembers": 1,
|
||||||
|
"_bootstrapGuard": "Gitea #33 guard ENABLED on the docker rig (2026-08-02): deploy.sh recreates all containers simultaneously, which twice split site pairs into two 1-node clusters on 2026-08-01. Lower host:port founds self-first; the higher node TCP-probes then joins peer-first.",
|
||||||
|
"BootstrapGuard": {
|
||||||
|
"Enabled": true,
|
||||||
|
"PartnerProbeSeconds": 25,
|
||||||
|
"PartnerProbeIntervalMs": 500,
|
||||||
|
"ProbeConnectTimeoutMs": 1000
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
"Database": {
|
||||||
"_comment": "ConfigurationDb/MachineDataDb (which carry the dev SQL password) are supplied as ScadaBridge__Database__* whole-key env overrides in docker/docker-compose.yml (dev-only-insecure, mirroring the ApiKeyPepper convention). The same dev password already ships committed for the sibling scadabridge-mssql container (infra/docker-compose.yml MSSQL_SA_PASSWORD) — this is consolidation, not new exposure. Env overrides layer over JSON before the ${secret:} expander runs, so the dev cluster boots with no KEK/secret store. Real/prod config uses ${secret:} tokens in src/.../appsettings.Central.json (T4)."
|
"_comment": "ConfigurationDb/MachineDataDb (which carry the dev SQL password) are supplied as ScadaBridge__Database__* whole-key env overrides in docker/docker-compose.yml (dev-only-insecure, mirroring the ApiKeyPepper convention). The same dev password already ships committed for the sibling scadabridge-mssql container (infra/docker-compose.yml MSSQL_SA_PASSWORD) — this is consolidation, not new exposure. Env overrides layer over JSON before the ${secret:} expander runs, so the dev cluster boots with no KEK/secret store. Real/prod config uses ${secret:} tokens in src/.../appsettings.Central.json (T4)."
|
||||||
|
|||||||
@@ -1,12 +1,81 @@
|
|||||||
|
# ── Clustered secret replication: the pull-only gRPC hub (scadaproj#3) ─────────
|
||||||
|
#
|
||||||
|
# Central hosts the hub on its EXISTING h2c control-plane listener (CentralGrpcPort
|
||||||
|
# 8083, alongside CentralControlService); each site node sweeps it on an interval and
|
||||||
|
# writes what it pulls into its OWN local SQLite store. Nothing but ciphertext crosses
|
||||||
|
# the wire, so every participating node must resolve the SAME KEK.
|
||||||
|
#
|
||||||
|
# ENABLED ON FOUR NODES ONLY: the central pair (hub) and the site-a pair (followers).
|
||||||
|
# site-b and site-c are deliberately left without it, so the default-OFF posture is
|
||||||
|
# proven side by side on one rig — exactly as site-a is the rig's only LocalDb-replicated
|
||||||
|
# pair. A node with no Secrets__* override keeps the shipped appsettings default
|
||||||
|
# (Replication:Enabled=false, Mode=SqlServer) and composes a plain local store.
|
||||||
|
#
|
||||||
|
# ALL VALUES HERE ARE DEV-ONLY and committed under the same exception as the mesh PSKs
|
||||||
|
# and the ApiKeyPepper above: a local docker rig needs a working credential in source
|
||||||
|
# control to boot. Production supplies the KEK out of band (ZB_SECRETS_MASTER_KEY, never
|
||||||
|
# committed) and the hub token from appsettings/env — NEVER as a ${secret:} reference,
|
||||||
|
# since resolving one is what the hub exists to make possible.
|
||||||
|
x-secrets-hub-env: &secrets-hub-env
|
||||||
|
# DEV-ONLY KEK — NOT a real key. Identical on all four participating nodes: only
|
||||||
|
# ciphertext replicates, so a node with a different KEK fails closed on resolve with a
|
||||||
|
# kek_id mismatch that reads like corruption but is a deployment error.
|
||||||
|
ZB_SECRETS_MASTER_KEY: "zZiBWuoaVMbJmGXToLk9Lakw0iJozXoL/7Gxac3GwJ4="
|
||||||
|
# The appsettings default is the relative "scadabridge-secrets.db", which resolves to
|
||||||
|
# /app — inside the image's writable layer, so it is destroyed by any container
|
||||||
|
# recreate and unreachable from the host. /app/data is the node's own mounted volume
|
||||||
|
# (the one LocalDb already uses on sites; added to the central pair for this).
|
||||||
|
Secrets__SqlitePath: "/app/data/scadabridge-secrets.db"
|
||||||
|
Secrets__Replication__Enabled: "true"
|
||||||
|
Secrets__Replication__Mode: "Grpc"
|
||||||
|
# DEV-ONLY shared bearer token — NOT a real secret. Presented by every follower and
|
||||||
|
# verified by the hub's fail-closed SecretsHubAuthInterceptor. Must be IDENTICAL on the
|
||||||
|
# hub and every follower; an unset token is a startup failure on both halves.
|
||||||
|
Secrets__GrpcHub__BearerToken: "secrets-hub-docker-dev-token"
|
||||||
|
|
||||||
|
# Central-only half: the SHARED SQL-Server secret store (scadaproj#4, Secrets 0.5.0).
|
||||||
|
# In Grpc mode BOTH central nodes read and write ONE copy of every row in this database,
|
||||||
|
# so the two hub instances serve identical manifests by construction — that is what makes
|
||||||
|
# the site-side FallbackEndpoints below safe. ZbSecretsHub is a dedicated database on the
|
||||||
|
# rig's existing scadabridge-mssql container (default zbsecrets schema, created by the
|
||||||
|
# boot-time SqlServerSecretsStoreMigrator; the database itself + the scadabridge_app grant
|
||||||
|
# were provisioned once via sqlcmd — see docs/plans/2026-08-07-secrets-central-shared-store-live-gate.md).
|
||||||
|
# Same DEV-ONLY credentials as the ScadaBridge__Database__* strings above. Must be a
|
||||||
|
# LITERAL value, never a ${secret:} reference — the pre-host expander needs this string to
|
||||||
|
# reach the store that would resolve it (bootstrap circularity; registration rejects it).
|
||||||
|
# Site nodes must NEVER carry this key: sites talk to central, not to central's database.
|
||||||
|
x-secrets-hub-central-env: &secrets-hub-central-env
|
||||||
|
Secrets__SqlServer__ConnectionString: "Server=scadabridge-mssql,1433;Database=ZbSecretsHub;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
||||||
|
|
||||||
|
# Site half of the same section. The hub client dials Endpoint first and fails over, per
|
||||||
|
# call, to FallbackEndpoints in order (sticky on whichever answered last, Secrets 0.5.0).
|
||||||
|
# Listing central-b is safe ONLY because both centrals serve the one shared SQL store
|
||||||
|
# above — never list endpoints backed by independent stores: failing over to an emptier
|
||||||
|
# hub is a silent convergence stop, the exact defect (scadaproj#4) the shared store
|
||||||
|
# exists to prevent.
|
||||||
|
x-secrets-hub-site-env: &secrets-hub-site-env
|
||||||
|
Secrets__GrpcHub__Endpoint: "http://scadabridge-central-a:8083"
|
||||||
|
Secrets__GrpcHub__FallbackEndpoints__0: "http://scadabridge-central-b:8083"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
central-a:
|
central-a:
|
||||||
image: scadabridge:latest
|
image: scadabridge:latest
|
||||||
|
# An init process (tini) as PID 1, so a crashed dotnet process actually dies.
|
||||||
|
# Without it dotnet IS PID 1, Linux ignores the SIGABRT the runtime's crash path
|
||||||
|
# raises against PID 1, and any unhandled boot exception left the container
|
||||||
|
# `running` with the main thread spinning at 100% CPU — restart policy never
|
||||||
|
# fired (ScadaBridge#34). Belt to Program.cs's UnhandledException handler, which
|
||||||
|
# covers managed exceptions but not FailFast/runtime-internal aborts.
|
||||||
|
init: true
|
||||||
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
||||||
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
||||||
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
container_name: scadabridge-central-a
|
container_name: scadabridge-central-a
|
||||||
environment:
|
environment:
|
||||||
|
# Hub half of the pull-only gRPC secrets hub + the central-only shared SQL store
|
||||||
|
# (anchors at the top of this file).
|
||||||
|
<<: [*secrets-hub-env, *secrets-hub-central-env]
|
||||||
SCADABRIDGE_CONFIG: Central
|
SCADABRIDGE_CONFIG: Central
|
||||||
ASPNETCORE_ENVIRONMENT: Development
|
ASPNETCORE_ENVIRONMENT: Development
|
||||||
ASPNETCORE_URLS: "http://+:5000"
|
ASPNETCORE_URLS: "http://+:5000"
|
||||||
@@ -42,6 +111,11 @@ services:
|
|||||||
- "9013:8083" # gRPC control plane (CentralControlService, T1A.2)
|
- "9013:8083" # gRPC control plane (CentralControlService, T1A.2)
|
||||||
volumes:
|
volumes:
|
||||||
- ./central-node-a/appsettings.Central.json:/app/appsettings.Central.json:ro
|
- ./central-node-a/appsettings.Central.json:/app/appsettings.Central.json:ro
|
||||||
|
# Originally added for the gRPC secrets hub's local SQLite store; since the
|
||||||
|
# central store moved to the shared SQL-Server database (Secrets 0.5.0,
|
||||||
|
# scadaproj#4) the scadabridge-secrets.db here is a pre-0.5.0 residue, but the
|
||||||
|
# volume is still needed (inbound-api-keys.sqlite lives on it).
|
||||||
|
- ./central-node-a/data:/app/data
|
||||||
- ./central-node-a/logs:/app/logs
|
- ./central-node-a/logs:/app/logs
|
||||||
networks:
|
networks:
|
||||||
- scadabridge-net
|
- scadabridge-net
|
||||||
@@ -49,12 +123,16 @@ services:
|
|||||||
|
|
||||||
central-b:
|
central-b:
|
||||||
image: scadabridge:latest
|
image: scadabridge:latest
|
||||||
|
init: true # PID-1 crash wedge, ScadaBridge#34 — see central-a
|
||||||
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
||||||
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
||||||
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
container_name: scadabridge-central-b
|
container_name: scadabridge-central-b
|
||||||
environment:
|
environment:
|
||||||
|
# Hub half of the pull-only gRPC secrets hub + the central-only shared SQL store
|
||||||
|
# (anchors at the top of this file).
|
||||||
|
<<: [*secrets-hub-env, *secrets-hub-central-env]
|
||||||
SCADABRIDGE_CONFIG: Central
|
SCADABRIDGE_CONFIG: Central
|
||||||
ASPNETCORE_ENVIRONMENT: Development
|
ASPNETCORE_ENVIRONMENT: Development
|
||||||
ASPNETCORE_URLS: "http://+:5000"
|
ASPNETCORE_URLS: "http://+:5000"
|
||||||
@@ -90,6 +168,11 @@ services:
|
|||||||
- "9014:8083" # gRPC control plane (CentralControlService, T1A.2)
|
- "9014:8083" # gRPC control plane (CentralControlService, T1A.2)
|
||||||
volumes:
|
volumes:
|
||||||
- ./central-node-b/appsettings.Central.json:/app/appsettings.Central.json:ro
|
- ./central-node-b/appsettings.Central.json:/app/appsettings.Central.json:ro
|
||||||
|
# Originally added for the gRPC secrets hub's local SQLite store; since the
|
||||||
|
# central store moved to the shared SQL-Server database (Secrets 0.5.0,
|
||||||
|
# scadaproj#4) the scadabridge-secrets.db here is a pre-0.5.0 residue, but the
|
||||||
|
# volume is still needed (inbound-api-keys.sqlite lives on it).
|
||||||
|
- ./central-node-b/data:/app/data
|
||||||
- ./central-node-b/logs:/app/logs
|
- ./central-node-b/logs:/app/logs
|
||||||
networks:
|
networks:
|
||||||
- scadabridge-net
|
- scadabridge-net
|
||||||
@@ -97,12 +180,16 @@ services:
|
|||||||
|
|
||||||
site-a-a:
|
site-a-a:
|
||||||
image: scadabridge:latest
|
image: scadabridge:latest
|
||||||
|
init: true # PID-1 crash wedge, ScadaBridge#34 — see central-a
|
||||||
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
||||||
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
||||||
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
container_name: scadabridge-site-a-a
|
container_name: scadabridge-site-a-a
|
||||||
environment:
|
environment:
|
||||||
|
# Follower half of the pull-only gRPC secrets hub (anchors at the top of this
|
||||||
|
# file). site-b and site-c deliberately carry neither.
|
||||||
|
<<: [*secrets-hub-env, *secrets-hub-site-env]
|
||||||
SCADABRIDGE_CONFIG: Site
|
SCADABRIDGE_CONFIG: Site
|
||||||
ports:
|
ports:
|
||||||
- "9021:8082" # Akka remoting (host access for debugging)
|
- "9021:8082" # Akka remoting (host access for debugging)
|
||||||
@@ -117,12 +204,16 @@ services:
|
|||||||
|
|
||||||
site-a-b:
|
site-a-b:
|
||||||
image: scadabridge:latest
|
image: scadabridge:latest
|
||||||
|
init: true # PID-1 crash wedge, ScadaBridge#34 — see central-a
|
||||||
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
||||||
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
||||||
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
container_name: scadabridge-site-a-b
|
container_name: scadabridge-site-a-b
|
||||||
environment:
|
environment:
|
||||||
|
# Follower half of the pull-only gRPC secrets hub (anchors at the top of this
|
||||||
|
# file). site-b and site-c deliberately carry neither.
|
||||||
|
<<: [*secrets-hub-env, *secrets-hub-site-env]
|
||||||
SCADABRIDGE_CONFIG: Site
|
SCADABRIDGE_CONFIG: Site
|
||||||
ports:
|
ports:
|
||||||
- "9022:8082" # Akka remoting
|
- "9022:8082" # Akka remoting
|
||||||
@@ -137,6 +228,7 @@ services:
|
|||||||
|
|
||||||
site-b-a:
|
site-b-a:
|
||||||
image: scadabridge:latest
|
image: scadabridge:latest
|
||||||
|
init: true # PID-1 crash wedge, ScadaBridge#34 — see central-a
|
||||||
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
||||||
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
||||||
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
||||||
@@ -157,6 +249,7 @@ services:
|
|||||||
|
|
||||||
site-b-b:
|
site-b-b:
|
||||||
image: scadabridge:latest
|
image: scadabridge:latest
|
||||||
|
init: true # PID-1 crash wedge, ScadaBridge#34 — see central-a
|
||||||
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
||||||
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
||||||
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
||||||
@@ -177,6 +270,7 @@ services:
|
|||||||
|
|
||||||
site-c-a:
|
site-c-a:
|
||||||
image: scadabridge:latest
|
image: scadabridge:latest
|
||||||
|
init: true # PID-1 crash wedge, ScadaBridge#34 — see central-a
|
||||||
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
||||||
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
||||||
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
||||||
@@ -197,6 +291,7 @@ services:
|
|||||||
|
|
||||||
site-c-b:
|
site-c-b:
|
||||||
image: scadabridge:latest
|
image: scadabridge:latest
|
||||||
|
init: true # PID-1 crash wedge, ScadaBridge#34 — see central-a
|
||||||
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
||||||
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
||||||
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
||||||
|
|||||||
@@ -18,7 +18,14 @@
|
|||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
"MinNrOfMembers": 1
|
"MinNrOfMembers": 1,
|
||||||
|
"_bootstrapGuard": "Gitea #33 guard ENABLED on the docker rig (2026-08-02): deploy.sh recreates all containers simultaneously, which twice split site pairs into two 1-node clusters on 2026-08-01. Lower host:port founds self-first; the higher node TCP-probes then joins peer-first.",
|
||||||
|
"BootstrapGuard": {
|
||||||
|
"Enabled": true,
|
||||||
|
"PartnerProbeSeconds": 25,
|
||||||
|
"PartnerProbeIntervalMs": 500,
|
||||||
|
"ProbeConnectTimeoutMs": 1000
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
"Database": {
|
||||||
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
||||||
@@ -48,9 +55,9 @@
|
|||||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||||
// refuse every SiteStream call while the node still looked healthy.
|
// refuse every SiteStream call while the node still looked healthy.
|
||||||
"GrpcPsk": "dev-grpc-psk-docker-site-a",
|
"GrpcPsk": "dev-grpc-psk-docker-site-a",
|
||||||
"CentralContactPoints": [
|
"CentralGrpcEndpoints": [
|
||||||
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
"http://scadabridge-central-a:8083",
|
||||||
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
"http://scadabridge-central-b:8083"
|
||||||
],
|
],
|
||||||
"DeploymentTimeout": "00:02:00",
|
"DeploymentTimeout": "00:02:00",
|
||||||
"LifecycleTimeout": "00:00:30",
|
"LifecycleTimeout": "00:00:30",
|
||||||
|
|||||||
@@ -18,7 +18,14 @@
|
|||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
"MinNrOfMembers": 1
|
"MinNrOfMembers": 1,
|
||||||
|
"_bootstrapGuard": "Gitea #33 guard ENABLED on the docker rig (2026-08-02): deploy.sh recreates all containers simultaneously, which twice split site pairs into two 1-node clusters on 2026-08-01. Lower host:port founds self-first; the higher node TCP-probes then joins peer-first.",
|
||||||
|
"BootstrapGuard": {
|
||||||
|
"Enabled": true,
|
||||||
|
"PartnerProbeSeconds": 25,
|
||||||
|
"PartnerProbeIntervalMs": 500,
|
||||||
|
"ProbeConnectTimeoutMs": 1000
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
"Database": {
|
||||||
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
||||||
@@ -48,9 +55,9 @@
|
|||||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||||
// refuse every SiteStream call while the node still looked healthy.
|
// refuse every SiteStream call while the node still looked healthy.
|
||||||
"GrpcPsk": "dev-grpc-psk-docker-site-a",
|
"GrpcPsk": "dev-grpc-psk-docker-site-a",
|
||||||
"CentralContactPoints": [
|
"CentralGrpcEndpoints": [
|
||||||
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
"http://scadabridge-central-a:8083",
|
||||||
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
"http://scadabridge-central-b:8083"
|
||||||
],
|
],
|
||||||
"DeploymentTimeout": "00:02:00",
|
"DeploymentTimeout": "00:02:00",
|
||||||
"LifecycleTimeout": "00:00:30",
|
"LifecycleTimeout": "00:00:30",
|
||||||
|
|||||||
@@ -18,7 +18,14 @@
|
|||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
"MinNrOfMembers": 1
|
"MinNrOfMembers": 1,
|
||||||
|
"_bootstrapGuard": "Gitea #33 guard ENABLED on the docker rig (2026-08-02): deploy.sh recreates all containers simultaneously, which twice split site pairs into two 1-node clusters on 2026-08-01. Lower host:port founds self-first; the higher node TCP-probes then joins peer-first.",
|
||||||
|
"BootstrapGuard": {
|
||||||
|
"Enabled": true,
|
||||||
|
"PartnerProbeSeconds": 25,
|
||||||
|
"PartnerProbeIntervalMs": 500,
|
||||||
|
"ProbeConnectTimeoutMs": 1000
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
"Database": {
|
||||||
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
||||||
@@ -48,9 +55,9 @@
|
|||||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||||
// refuse every SiteStream call while the node still looked healthy.
|
// refuse every SiteStream call while the node still looked healthy.
|
||||||
"GrpcPsk": "dev-grpc-psk-docker-site-b",
|
"GrpcPsk": "dev-grpc-psk-docker-site-b",
|
||||||
"CentralContactPoints": [
|
"CentralGrpcEndpoints": [
|
||||||
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
"http://scadabridge-central-a:8083",
|
||||||
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
"http://scadabridge-central-b:8083"
|
||||||
],
|
],
|
||||||
"DeploymentTimeout": "00:02:00",
|
"DeploymentTimeout": "00:02:00",
|
||||||
"LifecycleTimeout": "00:00:30",
|
"LifecycleTimeout": "00:00:30",
|
||||||
|
|||||||
@@ -18,7 +18,14 @@
|
|||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
"MinNrOfMembers": 1
|
"MinNrOfMembers": 1,
|
||||||
|
"_bootstrapGuard": "Gitea #33 guard ENABLED on the docker rig (2026-08-02): deploy.sh recreates all containers simultaneously, which twice split site pairs into two 1-node clusters on 2026-08-01. Lower host:port founds self-first; the higher node TCP-probes then joins peer-first.",
|
||||||
|
"BootstrapGuard": {
|
||||||
|
"Enabled": true,
|
||||||
|
"PartnerProbeSeconds": 25,
|
||||||
|
"PartnerProbeIntervalMs": 500,
|
||||||
|
"ProbeConnectTimeoutMs": 1000
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
"Database": {
|
||||||
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
||||||
@@ -48,9 +55,9 @@
|
|||||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||||
// refuse every SiteStream call while the node still looked healthy.
|
// refuse every SiteStream call while the node still looked healthy.
|
||||||
"GrpcPsk": "dev-grpc-psk-docker-site-b",
|
"GrpcPsk": "dev-grpc-psk-docker-site-b",
|
||||||
"CentralContactPoints": [
|
"CentralGrpcEndpoints": [
|
||||||
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
"http://scadabridge-central-a:8083",
|
||||||
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
"http://scadabridge-central-b:8083"
|
||||||
],
|
],
|
||||||
"DeploymentTimeout": "00:02:00",
|
"DeploymentTimeout": "00:02:00",
|
||||||
"LifecycleTimeout": "00:00:30",
|
"LifecycleTimeout": "00:00:30",
|
||||||
|
|||||||
@@ -18,7 +18,14 @@
|
|||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
"MinNrOfMembers": 1
|
"MinNrOfMembers": 1,
|
||||||
|
"_bootstrapGuard": "Gitea #33 guard ENABLED on the docker rig (2026-08-02): deploy.sh recreates all containers simultaneously, which twice split site pairs into two 1-node clusters on 2026-08-01. Lower host:port founds self-first; the higher node TCP-probes then joins peer-first.",
|
||||||
|
"BootstrapGuard": {
|
||||||
|
"Enabled": true,
|
||||||
|
"PartnerProbeSeconds": 25,
|
||||||
|
"PartnerProbeIntervalMs": 500,
|
||||||
|
"ProbeConnectTimeoutMs": 1000
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
"Database": {
|
||||||
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
||||||
@@ -48,9 +55,9 @@
|
|||||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||||
// refuse every SiteStream call while the node still looked healthy.
|
// refuse every SiteStream call while the node still looked healthy.
|
||||||
"GrpcPsk": "dev-grpc-psk-docker-site-c",
|
"GrpcPsk": "dev-grpc-psk-docker-site-c",
|
||||||
"CentralContactPoints": [
|
"CentralGrpcEndpoints": [
|
||||||
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
"http://scadabridge-central-a:8083",
|
||||||
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
"http://scadabridge-central-b:8083"
|
||||||
],
|
],
|
||||||
"DeploymentTimeout": "00:02:00",
|
"DeploymentTimeout": "00:02:00",
|
||||||
"LifecycleTimeout": "00:00:30",
|
"LifecycleTimeout": "00:00:30",
|
||||||
|
|||||||
@@ -18,7 +18,14 @@
|
|||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
"HeartbeatInterval": "00:00:02",
|
"HeartbeatInterval": "00:00:02",
|
||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
"MinNrOfMembers": 1
|
"MinNrOfMembers": 1,
|
||||||
|
"_bootstrapGuard": "Gitea #33 guard ENABLED on the docker rig (2026-08-02): deploy.sh recreates all containers simultaneously, which twice split site pairs into two 1-node clusters on 2026-08-01. Lower host:port founds self-first; the higher node TCP-probes then joins peer-first.",
|
||||||
|
"BootstrapGuard": {
|
||||||
|
"Enabled": true,
|
||||||
|
"PartnerProbeSeconds": 25,
|
||||||
|
"PartnerProbeIntervalMs": 500,
|
||||||
|
"ProbeConnectTimeoutMs": 1000
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
"Database": {
|
||||||
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
||||||
@@ -48,9 +55,9 @@
|
|||||||
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
||||||
// refuse every SiteStream call while the node still looked healthy.
|
// refuse every SiteStream call while the node still looked healthy.
|
||||||
"GrpcPsk": "dev-grpc-psk-docker-site-c",
|
"GrpcPsk": "dev-grpc-psk-docker-site-c",
|
||||||
"CentralContactPoints": [
|
"CentralGrpcEndpoints": [
|
||||||
"akka.tcp://scadabridge@scadabridge-central-a:8081",
|
"http://scadabridge-central-a:8083",
|
||||||
"akka.tcp://scadabridge@scadabridge-central-b:8081"
|
"http://scadabridge-central-b:8083"
|
||||||
],
|
],
|
||||||
"DeploymentTimeout": "00:02:00",
|
"DeploymentTimeout": "00:02:00",
|
||||||
"LifecycleTimeout": "00:00:30",
|
"LifecycleTimeout": "00:00:30",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Central–Site Communication
|
# Central–Site Communication
|
||||||
|
|
||||||
The Central–Site Communication component is the transport layer that connects the central cluster to every site cluster. It provides three independent transports — Akka.NET `ClusterClient` for command/control, gRPC server-streaming for real-time data, and plain token-gated HTTP for the deployment-config fetch — anchored by a pair of actors that each cluster registers with the `ClusterClientReceptionist`.
|
The Central–Site Communication component is the transport layer that connects the central cluster to every site cluster. It provides three independent transports, **all now gRPC-based across the boundary** (Akka `ClusterClient` was removed in Phase 4 of the ClusterClient→gRPC migration, 2026-07-23): gRPC command/control in both directions (`CentralControlService` on central via `GrpcCentralTransport`; `SiteCommandService` on the site via `GrpcSiteTransport`), gRPC server-streaming for real-time data (`SiteStreamService`), and plain token-gated HTTP for the deployment-config fetch. Cross-cluster Akka remoting and `ClusterClientReceptionist` are gone; Akka remoting is now intra-cluster only.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -22,25 +22,26 @@ DI registration is called from the Host composition root via `AddCommunication`.
|
|||||||
|
|
||||||
| Transport | Who dials | Data direction | Purpose |
|
| Transport | Who dials | Data direction | Purpose |
|
||||||
|-----------|-----------|----------------|---------|
|
|-----------|-----------|----------------|---------|
|
||||||
| Akka.NET `ClusterClient` | both (central → site per site; site → central) | bidirectional | Deploy notifies, lifecycle, subscribe/unsubscribe handshake, snapshots, heartbeats, health reports, telemetry, notifications |
|
| gRPC command/control (`CentralControlService`) | **site dials central** | site → central | Heartbeats, health reports, notification submit/status, audit + cached-telemetry ingest, reconcile |
|
||||||
|
| gRPC command/control (`SiteCommandService`) | **central dials the site** | central → site | Deploy notifies, lifecycle, OPC UA, remote queries, subscribe/unsubscribe handshake, snapshots, parked, route, failover |
|
||||||
| gRPC (`SiteStreamService`) | **central dials the site** | mostly site → central | Real-time attribute value and alarm state changes (server-streaming), plus the audit ingest/pull unary RPCs |
|
| gRPC (`SiteStreamService`) | **central dials the site** | mostly site → central | Real-time attribute value and alarm state changes (server-streaming), plus the audit ingest/pull unary RPCs |
|
||||||
| HTTP `GET` (`/api/internal/deployments/{id}/config`) | **site dials central** | central → site | The flattened deployment config itself (notify-and-fetch), gated by a per-deployment `X-Deployment-Token` |
|
| HTTP `GET` (`/api/internal/deployments/{id}/config`) | **site dials central** | central → site | The flattened deployment config itself (notify-and-fetch), gated by a per-deployment `X-Deployment-Token` |
|
||||||
|
|
||||||
The transports are independent. A gRPC stream interruption does not affect in-flight `ClusterClient` commands, and vice versa.
|
The transports are independent. A gRPC stream interruption does not affect in-flight command/control calls, and vice versa.
|
||||||
|
|
||||||
**The gRPC dial direction is inverted from its data direction.** Values flow site → central, but each **site node hosts the gRPC server** and **central is the client**. `MapGrpcService<SiteStreamGrpcServer>()` appears exactly once in the tree, inside the Site branch of `Program.cs` (`Host/Program.cs:542`); there is **no gRPC server on a central node at all**. That is why the two `Ingest*` unary RPCs — nominally a central-side ingest surface — are dead in the shipped topology (acknowledged in `AkkaHostedService.cs:505-508`: "when the gRPC server is not registered (current central topology)"); sites push audit telemetry to central over `ClusterClient` instead, and central pulls with `PullAuditEvents` / `PullSiteCalls` by dialling the site.
|
**`SiteStreamService`'s gRPC dial direction is inverted from its data direction.** Values flow site → central, but each **site node hosts the `SiteStreamService` server** and **central is the client**. Command/control is different: it runs on its own two services — `CentralControlService` (central-hosted, site dials in) and `SiteCommandService` (site-hosted, central dials in). So a central node now DOES host a gRPC server (`CentralControlService`), unlike before Phase 4. The two legacy `Ingest*` unary RPCs on the site-hosted `SiteStreamService` remain dead in the shipped topology (no site dials a site for ingest); audit telemetry is pushed site → central over `CentralControlService`, and central pulls with `PullAuditEvents` / `PullSiteCalls` by dialling the site's `SiteStreamService`.
|
||||||
|
|
||||||
**No transport carries transport encryption; two of the three now carry authentication.**
|
**No transport carries transport encryption; two of the three now carry authentication.**
|
||||||
|
|
||||||
- **Akka remoting / `ClusterClient` — unauthenticated.** `BuildHocon` emits no `enable-ssl`, no secure cookie and no `trusted-selection-paths`, so the command/control path is plaintext and open to anything that can reach the remoting port.
|
- **Akka remoting — unauthenticated, but intra-cluster only.** `BuildHocon` emits no `enable-ssl`, no secure cookie and no `trusted-selection-paths`, so remoting is plaintext and open to anything that can reach the remoting port — but as of Phase 4 remoting no longer crosses the site↔central boundary, so this exposure is pair-internal.
|
||||||
- **gRPC — authenticated by preshared key since 2026-07-22.** The listener is still **h2c** — `ListenAnyIP(grpcPort, o => o.Protocols = HttpProtocols.Http2)` with no `UseHttps` — but `ControlPlaneAuthInterceptor` gates every method under `/sitestream.SiteStreamService/`, including the `PullAuditEvents` / `PullSiteCalls` RPCs that return audit rows. It is fail-closed (no key ⇒ everything refused, and `StartupValidator` will not boot a site node in that state), compares with `CryptographicOperations.FixedTimeEquals`, and rejects with `PermissionDenied`. Central attaches the key via `ControlPlaneCredentials`, which binds `CallCredentials` to each channel so unary and streaming calls are covered uniformly — `SiteStreamGrpcClient`, `GrpcPullAuditEventsInvoker` and `GrpcPullSiteCallsInvoker` all build their channels through it. Keys are **per site** (`SB-GRPC-PSK-<siteId>`), so a compromised site yields only its own. `LocalDbSyncAuthInterceptor` shares the listener and keeps its own separate key on `/localdb_sync.v1.LocalDbSync/` — the two authenticate different peers (central vs. the pair partner) and are never shared.
|
- **gRPC — authenticated by preshared key since 2026-07-22.** The listeners are still **h2c** — `ListenAnyIP(grpcPort, o => o.Protocols = HttpProtocols.Http2)` with no `UseHttps` — but `ControlPlaneAuthInterceptor` gates the command/control and streaming services alike: every method under `/sitestream.SiteStreamService/` (including the `PullAuditEvents` / `PullSiteCalls` RPCs that return audit rows) **and** the Phase-4 `CentralControlService` (site→central) / `SiteCommandService` (central→site). It is fail-closed (no key ⇒ everything refused, and `StartupValidator` will not boot a site node in that state), compares with `CryptographicOperations.FixedTimeEquals`, and rejects with `PermissionDenied`. The caller attaches the key via `ControlPlaneCredentials`, which binds `CallCredentials` to each channel so unary and streaming calls are covered uniformly. Keys are **per site** (`SB-GRPC-PSK-<siteId>`), so a compromised site yields only its own. `LocalDbSyncAuthInterceptor` shares the listener and keeps its own separate key on `/localdb_sync.v1.LocalDbSync/` — the two authenticate different peers (central vs. the pair partner) and are never shared.
|
||||||
- **HTTP config fetch — token-authenticated.** Its per-deployment token is the *entire* security boundary (the endpoint is `AllowAnonymous`).
|
- **HTTP config fetch — token-authenticated.** Its per-deployment token is the *entire* security boundary (the endpoint is `AllowAnonymous`).
|
||||||
|
|
||||||
A bearer PSK over plaintext h2c is readable and replayable by anyone on the path, so the design still assumes a trusted network between central and sites — but the bar is now "read the traffic" rather than "reach the port". TLS on these listeners is the follow-on hardening and would not change the key design. Operational detail: [`docs/deployment/topology-guide.md`](../deployment/topology-guide.md).
|
A bearer PSK over plaintext h2c is readable and replayable by anyone on the path, so the design still assumes a trusted network between central and sites — but the bar is now "read the traffic" rather than "reach the port". TLS on these listeners is the follow-on hardening and would not change the key design. Operational detail: [`docs/deployment/topology-guide.md`](../deployment/topology-guide.md).
|
||||||
|
|
||||||
### Notify-and-fetch: the deployment-config HTTP path
|
### Notify-and-fetch: the deployment-config HTTP path
|
||||||
|
|
||||||
An instance deployment does not carry its flattened configuration inside the Akka message. Central stages a `PendingDeployment` row (config JSON + a freshly generated `DeploymentFetchToken` + a TTL) and sends only a small `RefreshDeploymentCommand` over `ClusterClient`, carrying the deployment id, revision hash, `CentralFetchBaseUrl` and the fetch token. The site's Deployment Manager singleton then calls back to central over plain HTTP:
|
An instance deployment does not carry its flattened configuration inside the command message. Central stages a `PendingDeployment` row (config JSON + a freshly generated `DeploymentFetchToken` + a TTL) and sends only a small `RefreshDeploymentCommand` over gRPC command/control (`SiteCommandService`), carrying the deployment id, revision hash, `CentralFetchBaseUrl` and the fetch token. The site's Deployment Manager singleton then calls back to central over plain HTTP:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// SiteRuntime/Deployment/HttpDeploymentConfigFetcher.cs
|
// SiteRuntime/Deployment/HttpDeploymentConfigFetcher.cs
|
||||||
@@ -49,11 +50,11 @@ using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
|||||||
req.Headers.Add("X-Deployment-Token", token);
|
req.Headers.Add("X-Deployment-Token", token);
|
||||||
```
|
```
|
||||||
|
|
||||||
`DeploymentConfigEndpoints.Resolve` (`ManagementService/DeploymentConfigEndpoints.cs:101`) checks existence and TTL *before* the token, so unknown, superseded and expired deployments are all indistinguishable `404`s; a live row with a wrong or missing token is `401`. The token comparison is constant-time. This exists because a flattened config can exceed the default 128 KB Akka frame size, which drops the single oversized message without tearing down the association — heartbeats keep flowing, the site still reports healthy, and the deploy just hangs to its Ask timeout. See `docs/known-issues/2026-06-26-deploy-config-exceeds-akka-frame-size.md`. `DeployArtifactsCommand` was **not** moved to this path and still carries its payload inline.
|
`DeploymentConfigEndpoints.Resolve` (`ManagementService/DeploymentConfigEndpoints.cs:101`) checks existence and TTL *before* the token, so unknown, superseded and expired deployments are all indistinguishable `404`s; a live row with a wrong or missing token is `401`. The token comparison is constant-time. This path was originally introduced because a flattened config could exceed the default 128 KB Akka frame size when command/control rode ClusterClient — the oversized message was silently dropped and the deploy hung to its timeout (see `docs/known-issues/2026-06-26-deploy-config-exceeds-akka-frame-size.md`). That Akka frame limit no longer applies now that command/control rides gRPC (`SiteCommandService`, 4 MB cap), but notify-and-fetch remains the deploy path. `DeployArtifactsCommand` was **not** moved to this path and still carries its payload inline — now over gRPC, so it is no longer at risk of the 128 KB drop.
|
||||||
|
|
||||||
### Hub-and-spoke topology
|
### Hub-and-spoke topology
|
||||||
|
|
||||||
Sites do not communicate with each other. All inter-cluster traffic flows through central. Central maintains one `ClusterClient` per site; each site maintains a single `ClusterClient` pointed at both central nodes.
|
Sites do not communicate with each other. All inter-cluster traffic flows through central. Central maintains one `GrpcSiteTransport` channel pair per site (dialling `SiteCommandService`); each site maintains a single `GrpcCentralTransport` channel pair pointed at both central nodes (dialling `CentralControlService`).
|
||||||
|
|
||||||
### `SiteEnvelope` routing
|
### `SiteEnvelope` routing
|
||||||
|
|
||||||
@@ -94,12 +95,14 @@ If a site is unreachable when a command arrives, the caller's Ask times out. Cen
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
> **Phase 4 note (2026-07-23).** The `ClusterClient` mechanics described in this section — `SiteEnvelope` routing via `ClusterClient.Send("/user/site-communication", …)`, one `ClusterClient` per site on `CentralCommunicationActor`, the site's outbound `_centralClient`, and `ClusterClientReceptionist` registration — were **replaced by gRPC command/control** in Phase 4 of the ClusterClient→gRPC migration. Central→site commands now go over `GrpcSiteTransport` → the site-hosted `SiteCommandService` (per-site NodeA→NodeB failover channel pair), and site→central messages over `GrpcCentralTransport` → the central-hosted `CentralControlService` (sticky central-a→central-b pair). Endpoints are dialled directly (no receptionist, no cross-cluster actor discovery). The per-site address load below still runs, but it now feeds a **per-site gRPC-endpoint cache** (from `GrpcNodeAAddress`/`GrpcNodeBAddress`) consumed by a `SitePairChannelProvider`, not a `ClusterClient` contact set. See [Component-Communication.md](../requirements/Component-Communication.md) for the current design.
|
||||||
|
|
||||||
### Central-side: `CentralCommunicationActor`
|
### Central-side: `CentralCommunicationActor`
|
||||||
|
|
||||||
`CentralCommunicationActor` is a `ReceiveActor` created at `/user/central-communication` and registered with `ClusterClientReceptionist` so the site's `ClusterClient` can locate it. It owns:
|
`CentralCommunicationActor` is a `ReceiveActor` created at `/user/central-communication` and registered with `ClusterClientReceptionist` so the site's `ClusterClient` can locate it. It owns:
|
||||||
|
|
||||||
- A `Dictionary<string, (IActorRef Client, ImmutableHashSet<string> ContactAddresses)>` keyed by site identifier — one `ClusterClient` per site.
|
- A `Dictionary<string, (IActorRef Client, ImmutableHashSet<string> ContactAddresses)>` keyed by site identifier — one `ClusterClient` per site.
|
||||||
- A `RefreshSiteAddresses` periodic timer (60-second cadence, starting immediately). Each tick fires `LoadSiteAddressesFromDb`, which reads every `Site` row from the database, parses `NodeAAddress` and `NodeBAddress` into Akka receptionist paths (`{addr}/system/receptionist`), and pipes a `SiteAddressCacheLoaded` message back to Self. `HandleSiteAddressCacheLoaded` creates, updates, or stops `ClusterClient` actors based on the diff.
|
- A `RefreshSiteAddresses` periodic timer (60-second cadence, starting immediately). Each tick fires `LoadSiteAddressesFromDb`, which reads every `Site` row from the database and (as of Phase 4) builds a **per-site gRPC-endpoint cache** from `GrpcNodeAAddress`/`GrpcNodeBAddress`, piping a `SiteAddressCacheLoaded` message back to Self. `HandleSiteAddressCacheLoaded` reconciles the diff, updating the `SitePairChannelProvider` that `GrpcSiteTransport` uses to dial each site's `SiteCommandService`. (Before Phase 4 this parsed the Akka `NodeAAddress`/`NodeBAddress` into receptionist paths and created/updated/stopped a `ClusterClient` per site.)
|
||||||
- Proxy references to `NotificationOutboxActor` and `AuditLogIngestActor` cluster singletons, injected post-construction via `RegisterNotificationOutbox` / `RegisterAuditIngest` messages from the Host. Messages that arrive before the proxy is registered are answered with a non-accepted ack (notifications) or an empty reply (audit), so the site retries without data loss.
|
- Proxy references to `NotificationOutboxActor` and `AuditLogIngestActor` cluster singletons, injected post-construction via `RegisterNotificationOutbox` / `RegisterAuditIngest` messages from the Host. Messages that arrive before the proxy is registered are answered with a non-accepted ack (notifications) or an empty reply (audit), so the site retries without data loss.
|
||||||
- Fanout of `SiteHealthReport` to the peer central node via `DistributedPubSub`, keyed on the `site-health-replica` topic, so both central nodes' aggregators stay in sync regardless of which central node the site's `ClusterClient` load-balanced the report to.
|
- Fanout of `SiteHealthReport` to the peer central node via `DistributedPubSub`, keyed on the `site-health-replica` topic, so both central nodes' aggregators stay in sync regardless of which central node the site's `ClusterClient` load-balanced the report to.
|
||||||
|
|
||||||
@@ -212,7 +215,7 @@ Central callers interact through `CommunicationService`, which wraps each comman
|
|||||||
| Instance deployment (notify-and-fetch) | `RefreshDeploymentAsync` | 120 s |
|
| Instance deployment (notify-and-fetch) | `RefreshDeploymentAsync` | 120 s |
|
||||||
| Instance lifecycle | `DisableInstanceAsync`, `EnableInstanceAsync`, `DeleteInstanceAsync` | 30 s |
|
| Instance lifecycle | `DisableInstanceAsync`, `EnableInstanceAsync`, `DeleteInstanceAsync` | 30 s |
|
||||||
| Artifact deployment | `DeployArtifactsAsync` | 60 s |
|
| Artifact deployment | `DeployArtifactsAsync` | 60 s |
|
||||||
| Integration routing | `RouteIntegrationCallAsync` | 30 s |
|
| Integration routing (Inbound API routed-site-script) | `RouteToCallAsync`, `RouteToGetAttributesAsync`, `RouteToSetAttributesAsync`, `RouteToWaitForAttributeAsync` | 30 s (`IntegrationTimeout`) |
|
||||||
| Debug snapshot | `RequestDebugSnapshotAsync` | 30 s |
|
| Debug snapshot | `RequestDebugSnapshotAsync` | 30 s |
|
||||||
| Remote queries | `QueryEventLogsAsync`, `QueryParkedMessagesAsync`, etc. | 30 s |
|
| Remote queries | `QueryEventLogsAsync`, `QueryParkedMessagesAsync`, etc. | 30 s |
|
||||||
| OPC UA tag browse | `BrowseNodeAsync` | 30 s |
|
| OPC UA tag browse | `BrowseNodeAsync` | 30 s |
|
||||||
@@ -236,7 +239,7 @@ All options are bound from the `ScadaBridge:Communication` section via `Communic
|
|||||||
| `IntegrationTimeout` | `00:00:30` | Ask timeout for integration routing and Inbound API routing. |
|
| `IntegrationTimeout` | `00:00:30` | Ask timeout for integration routing and Inbound API routing. |
|
||||||
| `DebugViewTimeout` | `00:00:10` | Ask timeout for debug subscribe/unsubscribe handshake. |
|
| `DebugViewTimeout` | `00:00:10` | Ask timeout for debug subscribe/unsubscribe handshake. |
|
||||||
| `NotificationForwardTimeout` | `00:00:30` | Ask timeout for notification submission forwarding. |
|
| `NotificationForwardTimeout` | `00:00:30` | Ask timeout for notification submission forwarding. |
|
||||||
| `CentralContactPoints` | `[]` | Site-side: Akka addresses of central nodes, e.g. `akka.tcp://scadabridge@central-a:8081`. |
|
| `CentralGrpcEndpoints` | `[]` | Site-side: gRPC h2c endpoints of the central nodes' `CentralControlService`, e.g. `http://scadabridge-central-a:8083` (central's `CentralGrpcPort`, default 8083 — direct, not via Traefik, which is HTTP/1 only). **Required on Site nodes** (at least one); empty on Central nodes, which host `CentralControlService` and do not dial it. Replaces the former `CentralContactPoints` Akka-address list. |
|
||||||
| `GrpcKeepAlivePingDelay` | `00:00:15` | HTTP/2 keepalive PING interval on `SiteStreamGrpcClient`. |
|
| `GrpcKeepAlivePingDelay` | `00:00:15` | HTTP/2 keepalive PING interval on `SiteStreamGrpcClient`. |
|
||||||
| `GrpcKeepAlivePingTimeout` | `00:00:10` | HTTP/2 keepalive PING timeout. |
|
| `GrpcKeepAlivePingTimeout` | `00:00:10` | HTTP/2 keepalive PING timeout. |
|
||||||
| `GrpcMaxStreamLifetime` | `04:00:00` | Per-stream session timeout; forces reconnect of zombie streams. |
|
| `GrpcMaxStreamLifetime` | `04:00:00` | Per-stream session timeout; forces reconnect of zombie streams. |
|
||||||
@@ -259,27 +262,27 @@ Three layers of dead-client detection protect the gRPC stream path:
|
|||||||
## Dependencies & Interactions
|
## Dependencies & Interactions
|
||||||
|
|
||||||
- [Commons (#16)](./Commons.md) — owns all message contracts used by this component: `DeployInstanceCommand`, `SiteEnvelope`, `HeartbeatMessage`, `SiteHealthReport`, `SiteHealthReportReplica`, `RegisterNotificationOutbox`, `RegisterAuditIngest`, `IngestAuditEventsCommand`, `IngestCachedTelemetryCommand`, and all other request/response records. Commons does not hold an Akka package reference, so `RegisterAuditIngest` (which carries an `IActorRef`) lives in this project.
|
- [Commons (#16)](./Commons.md) — owns all message contracts used by this component: `DeployInstanceCommand`, `SiteEnvelope`, `HeartbeatMessage`, `SiteHealthReport`, `SiteHealthReportReplica`, `RegisterNotificationOutbox`, `RegisterAuditIngest`, `IngestAuditEventsCommand`, `IngestCachedTelemetryCommand`, and all other request/response records. Commons does not hold an Akka package reference, so `RegisterAuditIngest` (which carries an `IActorRef`) lives in this project.
|
||||||
- [Cluster Infrastructure (#13)](./ClusterInfrastructure.md) — provides `ClusterClientReceptionist` registration and the oldest-`Up` active/standby model that `SiteCommunicationActor`'s `IsActive` stamp depends on, plus the single `"scadabridge"` `ActorSystem` name that makes cross-cluster `ClusterClient` addressing possible at all. `CentralCommunicationActor`'s `DistributedPubSub` fanout keeps both central nodes in sync regardless of which one a site's report landed on.
|
- [Cluster Infrastructure (#13)](./ClusterInfrastructure.md) — provides ClusterSingleton and the oldest-`Up` active/standby model that `SiteCommunicationActor`'s `IsActive` stamp depends on, plus the single `"scadabridge"` `ActorSystem` name for intra-cluster remoting. (`ClusterClientReceptionist` is no longer used — cross-cluster messaging is gRPC as of Phase 4.) `CentralCommunicationActor`'s `DistributedPubSub` fanout keeps both central nodes in sync regardless of which one a site's report landed on.
|
||||||
- [Configuration Database (#17)](./ConfigurationDatabase.md) — provides `ISiteRepository.GetAllSitesAsync` for address loading; site records carry `NodeAAddress`, `NodeBAddress`, `GrpcNodeAAddress`, `GrpcNodeBAddress`.
|
- [Configuration Database (#17)](./ConfigurationDatabase.md) — provides `ISiteRepository.GetAllSitesAsync` for address loading; site records carry `NodeAAddress`, `NodeBAddress`, `GrpcNodeAAddress`, `GrpcNodeBAddress`.
|
||||||
- [Deployment Manager (#2)](./DeploymentManager.md) — the primary consumer of command/control patterns 1–3. `CommunicationService` is injected into the Deployment Manager actor to send deploy notifies, lifecycle commands, and artifact deployments to sites. It also owns the staging half of the notify-and-fetch HTTP path (`PendingDeployment` rows + fetch tokens); the endpoint itself is served by the Management Service.
|
- [Deployment Manager (#2)](./DeploymentManager.md) — the primary consumer of command/control patterns 1–3. `CommunicationService` is injected into the Deployment Manager actor to send deploy notifies, lifecycle commands, and artifact deployments to sites. It also owns the staging half of the notify-and-fetch HTTP path (`PendingDeployment` rows + fetch tokens); the endpoint itself is served by the Management Service.
|
||||||
- [Site Runtime (#3)](./SiteRuntime.md) — `SiteCommunicationActor` forwards inbound commands to the `DeploymentManager` singleton proxy. `SiteStreamManager` (in Site Runtime) implements `ISiteStreamSubscriber` so `SiteStreamGrpcServer` can subscribe relay actors to instance event feeds without referencing Site Runtime directly.
|
- [Site Runtime (#3)](./SiteRuntime.md) — `SiteCommunicationActor` forwards inbound commands to the `DeploymentManager` singleton proxy. `SiteStreamManager` (in Site Runtime) implements `ISiteStreamSubscriber` so `SiteStreamGrpcServer` can subscribe relay actors to instance event feeds without referencing Site Runtime directly.
|
||||||
- [Health Monitoring (#11)](./HealthMonitoring.md) — `CentralCommunicationActor` calls `ICentralHealthAggregator.MarkHeartbeat` and `ProcessReport` for every inbound heartbeat and health report. `DistributedPubSub` fanout keeps both central nodes' aggregators in sync.
|
- [Health Monitoring (#11)](./HealthMonitoring.md) — `CentralCommunicationActor` calls `ICentralHealthAggregator.MarkHeartbeat` and `ProcessReport` for every inbound heartbeat and health report. `DistributedPubSub` fanout keeps both central nodes' aggregators in sync.
|
||||||
- [Audit Log (#23)](./AuditLog.md) — `SiteStreamGrpcServer` hosts the `IngestAuditEvents`, `IngestCachedTelemetry`, `PullAuditEvents` and `PullSiteCalls` RPCs. Because there is no central gRPC server, the `Ingest*` pair is unused in the shipped topology: sites push audit telemetry over ClusterClient, and `CentralCommunicationActor` routes `IngestAuditEventsCommand` / `IngestCachedTelemetryCommand` to the `AuditLogIngestActor` proxy. The `Pull*` reconciliation RPCs run the other way, with the central `site-audit-reconciliation` singleton dialling each site.
|
- [Audit Log (#23)](./AuditLog.md) — `SiteStreamGrpcServer` hosts the `IngestAuditEvents`, `IngestCachedTelemetry`, `PullAuditEvents` and `PullSiteCalls` RPCs. The `Ingest*` pair on the site-hosted service stays unused in the shipped topology; sites push audit telemetry site→central over gRPC to `CentralControlService`, which routes `IngestAuditEventsCommand` / `IngestCachedTelemetryCommand` to the `AuditLogIngestActor` proxy. The `Pull*` reconciliation RPCs run the other way, with the central `site-audit-reconciliation` singleton dialling each site's `SiteStreamService`.
|
||||||
- [Notification Outbox (#21)](./NotificationOutbox.md) — `CentralCommunicationActor` routes `NotificationSubmit` / `NotificationStatusQuery` messages from sites to the `NotificationOutboxActor` proxy. `CommunicationService` Asks the proxy directly for central-UI outbox management calls.
|
- [Notification Outbox (#21)](./NotificationOutbox.md) — `CentralCommunicationActor` routes `NotificationSubmit` / `NotificationStatusQuery` messages from sites to the `NotificationOutboxActor` proxy. `CommunicationService` Asks the proxy directly for central-UI outbox management calls.
|
||||||
- [Site Call Audit (#22)](./SiteCallAudit.md) — `CommunicationService` Asks the `SiteCallAuditActor` proxy directly for query and relay operations. `SiteCallAuditActor` issues `RetryParkedOperation` / `DiscardParkedOperation` relay commands to sites via `SiteEnvelope`; `SiteCommunicationActor` dispatches them to `_parkedMessageHandler`.
|
- [Site Call Audit (#22)](./SiteCallAudit.md) — `CommunicationService` Asks the `SiteCallAuditActor` proxy directly for query and relay operations. `SiteCallAuditActor` issues `RetryParkedOperation` / `DiscardParkedOperation` relay commands to sites via `SiteEnvelope`; `SiteCommunicationActor` dispatches them to `_parkedMessageHandler`.
|
||||||
- [Store-and-Forward Engine (#6)](./StoreAndForward.md) — the site S&F Engine drives `NotificationSubmit` forwarding and cached-call telemetry emission through `SiteCommunicationActor`. Parked-message queries and retry/discard relay commands flow back the other way.
|
- [Store-and-Forward Engine (#6)](./StoreAndForward.md) — the site S&F Engine drives `NotificationSubmit` forwarding and cached-call telemetry emission through `SiteCommunicationActor`. Parked-message queries and retry/discard relay commands flow back the other way.
|
||||||
- [Management Service (#18)](./ManagementService.md) — `ManagementActor` runs at `/user/management` on central and is reached **in-process** through `ManagementActorHolder`; the CLI connects over HTTP, not `ClusterClient`. (It was `ClusterClientReceptionist`-registered until 2026-07-22, for a CLI that was never built that way.) So this component's `ClusterClient` usage is exclusively the inter-cluster hub-and-spoke connections. Management Service also hosts `DeploymentConfigEndpoints` — the `GET /api/internal/deployments/{id}/config` route that terminates the third (HTTP) transport, mapped in the central-role block alongside `/api/audit/*` and `/management`.
|
- [Management Service (#18)](./ManagementService.md) — `ManagementActor` runs at `/user/management` on central and is reached **in-process** through `ManagementActorHolder`; the CLI connects over HTTP, not any cluster transport. (It was `ClusterClientReceptionist`-registered until 2026-07-22, for a CLI that was never built that way.) After Phase 4 this component uses no `ClusterClient` at all — its cross-cluster connections are the gRPC `CentralControlService` / `SiteCommandService` transports. Management Service also hosts `DeploymentConfigEndpoints` — the `GET /api/internal/deployments/{id}/config` route that terminates the HTTP deploy-config transport, mapped in the central-role block alongside `/api/audit/*` and `/management`.
|
||||||
- Design spec: [Component-Communication.md](../requirements/Component-Communication.md).
|
- Design spec: [Component-Communication.md](../requirements/Component-Communication.md).
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### A site's commands fail immediately
|
### A site's commands fail immediately
|
||||||
|
|
||||||
Check that `NodeAAddress` and `NodeBAddress` are populated in the site configuration — if both are empty, `CentralCommunicationActor` logs a warning and skips that site on every refresh, so no `ClusterClient` is created and all commands timeout. `CommunicationService.RefreshSiteAddresses()` triggers an on-demand refresh after an address is added.
|
Check that `GrpcNodeAAddress` and `GrpcNodeBAddress` are populated in the site configuration — if both are empty, `CentralCommunicationActor` logs a warning and skips that site on every refresh, so no `SiteCommandService` channel is built and all commands timeout. `CommunicationService.RefreshSiteAddresses()` triggers an on-demand refresh after an address is added.
|
||||||
|
|
||||||
### Commands are timing out but the site is reachable
|
### Commands are timing out but the site is reachable
|
||||||
|
|
||||||
A single malformed address string for one site can silently prevent `ClusterClient` creation for that site while other sites are unaffected. Check the logs for a `Warning` line from `HandleSiteAddressCacheLoaded` naming the offending site. The actor parse-guard catches the `ActorPath.Parse` exception per-site so the rest of the refresh proceeds.
|
A single malformed gRPC endpoint string for one site can silently prevent channel creation for that site while other sites are unaffected. Check the logs for a `Warning` line from `HandleSiteAddressCacheLoaded` naming the offending site. The per-site parse-guard skips the bad entry so the rest of the refresh proceeds.
|
||||||
|
|
||||||
A `Warning` at the `Status.Failure` handler in `CentralCommunicationActor` means `LoadSiteAddressesFromDb` itself threw (typically a SQL connection error); the cache is left stale until the next successful refresh.
|
A `Warning` at the `Status.Failure` handler in `CentralCommunicationActor` means `LoadSiteAddressesFromDb` itself threw (typically a SQL connection error); the cache is left stale until the next successful refresh.
|
||||||
|
|
||||||
@@ -291,7 +294,7 @@ After a site node failover, the `DebugStreamBridgeActor` attempts to reconnect t
|
|||||||
|
|
||||||
### Deployments fail immediately with a config-fetch error
|
### Deployments fail immediately with a config-fetch error
|
||||||
|
|
||||||
The site received the `RefreshDeploymentCommand` over ClusterClient but could not complete the HTTP leg. Check `CentralFetchBaseUrl` first — it must be reachable *from the site*, so a value that only resolves inside the central network fails every deploy. A `404` from the fetch means the staged row was unknown, superseded, or past `PendingDeploymentTtl`; a `401` means the row is live but the token did not match. Because the endpoint hides existence, a `404` cannot distinguish "wrong id" from "expired".
|
The site received the `RefreshDeploymentCommand` over gRPC command/control (`SiteCommandService`) but could not complete the HTTP leg. Check `CentralFetchBaseUrl` first — it must be reachable *from the site*, so a value that only resolves inside the central network fails every deploy. A `404` from the fetch means the staged row was unknown, superseded, or past `PendingDeploymentTtl`; a `401` means the row is live but the token did not match. Because the endpoint hides existence, a `404` cannot distinguish "wrong id" from "expired".
|
||||||
|
|
||||||
### Heartbeats arrive but health reports do not
|
### Heartbeats arrive but health reports do not
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,31 @@ In `Single` mode the component uses `SelectedKey` / `SelectedKeyChanged` (two-wa
|
|||||||
|
|
||||||
When `ContextMenu` is non-null, right-clicking any row suppresses the browser default and positions a Bootstrap `dropdown-menu show` div at the cursor coordinates using `position: fixed`. An invisible overlay behind the menu dismisses it on click-outside; Escape also dismisses it. The menu receives the `TItem` of the right-clicked node, so the consumer's fragment can branch on node type.
|
When `ContextMenu` is non-null, right-clicking any row suppresses the browser default and positions a Bootstrap `dropdown-menu show` div at the cursor coordinates using `position: fixed`. An invisible overlay behind the menu dismisses it on click-outside; Escape also dismisses it. The menu receives the `TItem` of the right-clicked node, so the consumer's fragment can branch on node type.
|
||||||
|
|
||||||
|
### Keyboard navigation & accessibility (WAI-ARIA tree pattern)
|
||||||
|
|
||||||
|
Delivered 2026-08-01 (M10 residual R7). The component implements the full [WAI-ARIA tree pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/).
|
||||||
|
|
||||||
|
**Roving tabindex.** Exactly one `li[role="treeitem"]` carries `tabindex="0"` at a time; every other node is `tabindex="-1"`, so the whole tree is a single Tab stop. The target is resolved once per render by `ResolveTabbableKey()`: the node the user last landed on (`_focusedKey`) while it is still visible → else the currently `SelectedKey` node → else the first visible node. A keyboard move sets `_focusNeedsApply`, and `OnAfterRenderAsync` pulls browser focus onto the new node via a per-node `ElementReference`, guarded by the same `JSException` / `JSDisconnectedException` / `InvalidOperationException` triple the context-menu focus uses (under bUnit there is no real focus, so it is a safe no-op).
|
||||||
|
|
||||||
|
| Key | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| `ArrowDown` / `ArrowUp` | Move to the next / previous **visible** node (a collapsed branch's children are skipped). No-op at the ends. |
|
||||||
|
| `ArrowRight` | Collapsed branch → expand (focus stays); expanded branch → move to first child; leaf → no-op. |
|
||||||
|
| `ArrowLeft` | Expanded branch → collapse (focus stays); otherwise → move to parent (via `BuildParentLookup()`). No-op at a root leaf. |
|
||||||
|
| `Home` / `End` | Move to the first / last visible node. |
|
||||||
|
| `Enter` / `Space` | Activate the node — routed to the *same* path a mouse click takes (`OnContentClick` in `Single` mode, `OnCheckboxToggle` in `Checkbox` mode), so selection semantics never diverge between input modes. |
|
||||||
|
|
||||||
|
The visible order is produced by `BuildVisibleNodes()`, which mirrors `RenderNode`'s visibility rules exactly — **the two must stay in step**, or arrow navigation will skip or invent rows.
|
||||||
|
|
||||||
|
**ARIA attributes.** `ul[role=tree]` root, `ul[role=group]` for children, `li[role=treeitem]` nodes carrying `aria-level` (1-based), `aria-posinset`, `aria-setsize`, `aria-expanded` (branches only), and `aria-selected` (selectable / checkbox trees only — absent on a non-selectable tree).
|
||||||
|
|
||||||
|
**Event scoping.** `@onkeydown:stopPropagation` sits on the `<li>` (so a nested node's keypress is not also handled by its ancestors) and on the chevron, the checkbox, and the `.tv-content` slot — so a consumer's own buttons and inputs inside `NodeContent` keep their key handling. Browser scroll-on-Space/Arrow is suppressed by a small **native** inline `onkeydown` on the root `<ul>` that calls `preventDefault()` only for the navigation keys and only when the event target is the treeitem itself; Blazor's `preventDefault` directive is all-or-nothing per element, so putting it on the `<li>` would both trap Tab inside the tree and cancel Enter/Space activation of consumer buttons. (The app sets no Content-Security-Policy, so the inline handler executes.)
|
||||||
|
|
||||||
|
Covered by `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs` (31 bUnit tests).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The component is a single `@typeparam` `.razor` file with a private `void RenderNode(TItem item, int depth)` local function that recurses the tree at render time — no intermediate view model is built inside the component. Every `<li>` carries `@key="key"` so Blazor can diff the list efficiently.
|
The component is a single `@typeparam` `.razor` file with a private `void RenderNode(TItem item, int depth, int posInSet, int setSize)` local function that recurses the tree at render time — no intermediate view model is built inside the component. Every `<li>` carries `@key="key"` so Blazor can diff the list efficiently.
|
||||||
|
|
||||||
`IJSRuntime` is injected for two purposes: reading/writing `sessionStorage` for expansion persistence, and setting `input.indeterminate` for tri-state checkboxes. Both call sites guard `JSDisconnectedException` so a disconnected circuit never throws out of the lifecycle methods.
|
`IJSRuntime` is injected for two purposes: reading/writing `sessionStorage` for expansion persistence, and setting `input.indeterminate` for tri-state checkboxes. Both call sites guard `JSDisconnectedException` so a disconnected circuit never throws out of the lifecycle methods.
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@
|
|||||||
- [ ] Windows Service account has minimum necessary permissions
|
- [ ] Windows Service account has minimum necessary permissions
|
||||||
- [ ] Log directory permissions restrict access to service account and administrators
|
- [ ] Log directory permissions restrict access to service account and administrators
|
||||||
- [ ] SMTP credentials use OAuth2 Client Credentials (preferred) or secure Basic Auth
|
- [ ] SMTP credentials use OAuth2 Client Credentials (preferred) or secure Basic Auth
|
||||||
|
- [ ] EWS transport (`Transport=Ews`): endpoint is an absolute `https://` URL and auth mode is Basic — Basic requires TLS and this is enforced at the write gate, the delivery adapter, and the sender
|
||||||
|
- [ ] EWS transport: the Exchange service-account password is rotated on the account-owner's schedule, and the SMTP configuration row is updated in the same change
|
||||||
- [ ] API keys for Inbound API are generated with sufficient entropy (32+ chars)
|
- [ ] API keys for Inbound API are generated with sufficient entropy (32+ chars)
|
||||||
|
|
||||||
### Network
|
### Network
|
||||||
@@ -51,7 +53,8 @@
|
|||||||
- [ ] DNS resolution works between all cluster nodes
|
- [ ] DNS resolution works between all cluster nodes
|
||||||
- [ ] Firewall rules permit Akka.NET remoting (TCP 8081)
|
- [ ] Firewall rules permit Akka.NET remoting (TCP 8081)
|
||||||
- [ ] Firewall rules permit LDAP (TCP 636 for LDAPS)
|
- [ ] Firewall rules permit LDAP (TCP 636 for LDAPS)
|
||||||
- [ ] Firewall rules permit SMTP (TCP 587 for TLS)
|
- [ ] Firewall rules permit SMTP (TCP 587 for TLS) — SMTP transport only
|
||||||
|
- [ ] EWS transport (`Transport=Ews`): firewall rules permit outbound HTTPS (TCP 443) from central nodes to the Exchange CAS instead of SMTP 587
|
||||||
- [ ] Firewall rules permit SQL Server (TCP 1433) from central nodes only
|
- [ ] Firewall rules permit SQL Server (TCP 1433) from central nodes only
|
||||||
- [ ] Load balancer health check configured against `/health/ready`
|
- [ ] Load balancer health check configured against `/health/ready`
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ Each site has its own two-node cluster:
|
|||||||
|
|
||||||
### Site Cluster Behavior
|
### Site Cluster Behavior
|
||||||
|
|
||||||
- Same split-brain resolver as central (keep-oldest).
|
- Same split-brain resolver as central (`auto-down`, per the 2026-07-21 decision — see the Central Cluster Behavior note above).
|
||||||
- Singleton actors: Site Deployment Manager migrates on failover.
|
- Singleton actors: Site Deployment Manager migrates on failover.
|
||||||
- Staggered instance startup: 50ms delay between Instance Actor creation to prevent reconnection storms.
|
- Staggered instance startup: 50ms delay between Instance Actor creation to prevent reconnection storms.
|
||||||
- SQLite persistence: each node owns its own consolidated LocalDb database, kept in step by
|
- SQLite persistence: each node owns its own consolidated LocalDb database, kept in step by
|
||||||
@@ -178,14 +178,22 @@ database from its peer rather than letting it rejoin.
|
|||||||
|
|
||||||
### Central-Site Communication
|
### Central-Site Communication
|
||||||
|
|
||||||
Three transports cross the boundary, not one:
|
Three transports cross the boundary, not one — **all now gRPC or HTTP; Akka ClusterClient was removed
|
||||||
|
in Phase 4 of the ClusterClient→gRPC migration (2026-07-23), and Akka remoting no longer crosses the
|
||||||
|
boundary at all:**
|
||||||
|
|
||||||
- **Akka ClusterClient** — command/control. Sites list every central node in
|
- **gRPC command/control** — both directions, on sticky-failover channel pairs, dialled directly (no
|
||||||
`ScadaBridge:Communication:CentralContactPoints`; contact rotation reaches whichever node
|
receptionist, no "active central" to identify — each side dials both of the peer's node endpoints):
|
||||||
answers, so no "active central" needs to be identified. (There is no `Communication:CentralSeedNode`
|
- *Site → central* to the central-hosted **`CentralControlService`** (`GrpcCentralTransport`): the
|
||||||
setting — earlier revisions of this guide named one that never existed in the code.)
|
site lists the central nodes' gRPC endpoints in `ScadaBridge:Communication:CentralGrpcEndpoints`
|
||||||
- **gRPC** — real-time data and audit pull. Note the direction is inverted from the data flow:
|
(e.g. `http://scadabridge-central-a:8083`, the central's `CentralGrpcPort`, default 8083 — direct
|
||||||
each **site node hosts the gRPC server** on `GrpcPort` (default 8083, h2c) and central dials in.
|
h2c, **not** via Traefik, which is HTTP/1 only). A Site node must list at least one; central nodes
|
||||||
|
leave it empty.
|
||||||
|
- *Central → site* to the site-hosted **`SiteCommandService`** (`GrpcSiteTransport`): central dials
|
||||||
|
the site's `GrpcNodeAAddress` / `GrpcNodeBAddress` (from the Site entity), NodeA→NodeB failover.
|
||||||
|
- **gRPC streaming + audit pull** — real-time data and audit/telemetry pull on the site-hosted
|
||||||
|
**`SiteStreamService`**. Note the direction is inverted from the data flow: each **site node hosts
|
||||||
|
the server** on `GrpcPort` (default 8083, h2c) and central dials in.
|
||||||
- **Plain HTTP** — the deploy config itself, fetched by the site with a per-deployment token.
|
- **Plain HTTP** — the deploy config itself, fetched by the site with a per-deployment token.
|
||||||
|
|
||||||
#### gRPC control-plane preshared key (required)
|
#### gRPC control-plane preshared key (required)
|
||||||
|
|||||||
@@ -31,6 +31,25 @@ Any deployment replicating wide rows must size that key deliberately; see the Ph
|
|||||||
Note the failure mode differs from the one documented below: an oversized gRPC message is
|
Note the failure mode differs from the one documented below: an oversized gRPC message is
|
||||||
**rejected**, not silently dropped.
|
**rejected**, not silently dropped.
|
||||||
|
|
||||||
|
## Amendment (2026-07-23) — Akka frame-size class RETIRED for site↔central command/control
|
||||||
|
|
||||||
|
Phase 4 of the ClusterClient→gRPC migration deleted the Akka `ClusterClient` site↔central transport
|
||||||
|
entirely. **All site↔central command/control now rides gRPC** — central→site over `SiteCommandService`
|
||||||
|
(`GrpcSiteTransport`) and site→central over `CentralControlService` (`GrpcCentralTransport`), each with
|
||||||
|
the gRPC default 4 MB message cap and **no per-message Akka frame limit**. Consequently:
|
||||||
|
|
||||||
|
- The 128 KB Akka `maximum-frame-size` constraint **no longer applies to any site↔central command
|
||||||
|
message**, including `DeployArtifactsCommand`, which still carries its payload inline but now travels
|
||||||
|
over gRPC.
|
||||||
|
- The **silent frame-drop failure mode** described below — the transport dropping one oversized message
|
||||||
|
while heartbeats keep flowing and the deploy hangs to its timeout — **cannot occur on that path any
|
||||||
|
more.** An over-cap gRPC message is *rejected* with an error, not silently discarded.
|
||||||
|
- The notify-and-fetch deploy path (the 2026-06-26 resolution) still stands and remains the deploy
|
||||||
|
mechanism; it is simply no longer the *only* thing keeping large payloads off a frame-limited hop.
|
||||||
|
- Akka remoting is now intra-cluster only, so its frame size governs only pair-internal traffic.
|
||||||
|
|
||||||
|
See `docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`.
|
||||||
|
|
||||||
The diagnosis below is retained as the historical record of how the bug was found and reasoned about.
|
The diagnosis below is retained as the historical record of how the bug was found and reasoned about.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|||||||
@@ -1,8 +1,55 @@
|
|||||||
# Cached-telemetry drain hot-loops forever on a row whose tracking snapshot is gone
|
# Cached-telemetry drain hot-loops forever on a row whose tracking snapshot is gone
|
||||||
|
|
||||||
**Date:** 2026-07-20 · **Status:** OPEN · **Severity:** Medium (log flood + wasted I/O; no data loss)
|
**Date:** 2026-07-20 · **Status:** RESOLVED (2026-07-20) · **Severity:** High — revised up from Medium on investigation (permanent stall of the cached-telemetry path, not just a log flood)
|
||||||
· **Area:** AuditLog / Site Telemetry
|
· **Area:** AuditLog / Site Telemetry
|
||||||
|
|
||||||
|
## Resolution (2026-07-20)
|
||||||
|
|
||||||
|
Fixed by letting an unresolvable row LEAVE the queue.
|
||||||
|
`SiteAuditTelemetryActor.OnCachedDrainAsync` now abandons the operational half of a cached row
|
||||||
|
whose tracking snapshot is still unresolvable after a grace period
|
||||||
|
(`SiteAuditTelemetryOptions.CachedTrackingGraceSeconds`, default **300 s**) and marks it
|
||||||
|
`Forwarded`. A row with no `CorrelationId` at all is abandoned immediately — it can never resolve.
|
||||||
|
|
||||||
|
**Marking `Forwarded` does not drop the audit data.** That state's role in this machine is "no
|
||||||
|
longer owed by the drain, still eligible for reconciliation", which is exactly the situation:
|
||||||
|
`ISiteAuditQueue.ReadPendingSinceAsync` covers `Forwarded` rows as well as `Pending` ones and
|
||||||
|
central dedups on `EventId`, so the reconciliation pull still delivers them. What is genuinely lost
|
||||||
|
is the operational (`SiteCalls`) half — unrecoverable regardless, since the tracking row it would
|
||||||
|
have been built from no longer exists.
|
||||||
|
|
||||||
|
Three further behaviours, each deliberate:
|
||||||
|
|
||||||
|
- **Inside the grace window the row is still retried.** A missing snapshot is normally a brief
|
||||||
|
write race (the audit row lands microseconds before the tracking row); abandoning on the first
|
||||||
|
failed lookup would discard the operational half of every cached call that lost that race.
|
||||||
|
- **A tracking-store THROW never abandons.** A throw is a store fault (locked, corrupt,
|
||||||
|
mid-restore), not a verdict about the row. Those rows stay `Pending` however old they are.
|
||||||
|
- **Logging is per drain pass, not per row.** One summary line each for abandoned, lookup-failed
|
||||||
|
(with the first exception attached) and deferred rows. The deferred case dropped to Debug — being
|
||||||
|
inside the grace window is normal operation, not a warning.
|
||||||
|
|
||||||
|
### Severity was revised UP during the fix
|
||||||
|
|
||||||
|
The original write-up called this a log flood with "no data loss", which understated it. The queue
|
||||||
|
is read **oldest-first with a fixed `BatchSize` (default 256)**, so once a batch's worth of
|
||||||
|
permanently-unresolvable rows collects at the head, every drain re-reads exactly those rows, fails
|
||||||
|
identically, and **never reaches the newer rows behind them**. The cached-telemetry path stalls
|
||||||
|
permanently. The audit half still reached central by reconciliation, so "no data loss" held — but
|
||||||
|
"Medium" did not.
|
||||||
|
|
||||||
|
**Fixed in:** `SiteAuditTelemetryActor.cs` (`AbandonUnresolvableAsync` + the rewritten skip
|
||||||
|
branches), `SiteAuditTelemetryOptions.cs` (`CachedTrackingGraceSeconds`).
|
||||||
|
**Tests:** `SiteAuditTelemetryActorTests` — `CachedDrain_OrphanRow_PastGrace_IsAbandoned_...`,
|
||||||
|
`..._InsideGrace_IsRetried_NotAbandoned`, `..._FullBatchOfUnresolvableRows_DoesNotStarveTheQueue`,
|
||||||
|
`..._TrackingStoreThrow_DoesNotAbandonTheRow`. The pre-existing
|
||||||
|
`CachedDrain_OrphanRow_NoTrackingSnapshot_IsSkipped_DoesNotCrash` was **retargeted, not deleted** —
|
||||||
|
it had pinned the defect ("skipped and stays Pending"), so its orphan assertion is inverted while
|
||||||
|
the half that still holds is kept verbatim. Verified non-vacuous: with abandonment disabled the two
|
||||||
|
abandonment tests go red; the two must-NOT-abandon guards stay green in both directions.
|
||||||
|
|
||||||
|
The diagnosis below is retained as the record of how it was found.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
`SiteAuditTelemetryActor`'s cached-telemetry drain reads Pending audit rows, looks up each row's
|
`SiteAuditTelemetryActor`'s cached-telemetry drain reads Pending audit rows, looks up each row's
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
# Integration call routing (`IntegrationCallRequest`) is dead on both ends
|
# Integration call routing (`IntegrationCallRequest`) is dead on both ends
|
||||||
|
|
||||||
**Date:** 2026-07-22 · **Status:** OPEN (decision needed: wire or delete) · **Severity:** Low (no
|
**Date:** 2026-07-22 · **Status:** RESOLVED (DELETED 2026-07-23) · **Tracked:** Gitea
|
||||||
runtime impact — the path cannot be reached) · **Area:** Central–Site Communication
|
[#32](https://gitea.dohertylan.com/dohertj2/ScadaBridge/issues/32) (filed 2026-07-23) · **Severity:**
|
||||||
|
Low (no runtime impact — the path cannot be reached) · **Area:** Central–Site Communication
|
||||||
|
|
||||||
|
> **Resolution (2026-07-23):** Decision = **delete** (option 1 below). Removed the
|
||||||
|
> `IntegrationCallRequest`/`IntegrationCallResponse` messages, `CommunicationService.RouteIntegrationCallAsync`,
|
||||||
|
> the `SiteCommunicationActor` receive block + `_integrationHandler` field + `LocalHandlerType.Integration`,
|
||||||
|
> and the four tests that covered them. `IntegrationTimeout` was **kept** — it is the live timeout for the
|
||||||
|
> Inbound API's `RouteTo*` verbs, which are the actual implementation of this "External → Central → Site →
|
||||||
|
> Central" pattern (design §4). The narrative comments that documented the exclusion (proto/mapper/dispatcher)
|
||||||
|
> were updated. Full solution build clean; Communication suite 634 green. This note is retained as the record.
|
||||||
|
|
||||||
## What
|
## What
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Secrets: Clustered Master-Key Posture (Central Pair)
|
# Secrets: Clustered Master-Key Posture (Central Pair)
|
||||||
|
|
||||||
|
> **Update 2026-08-07 (truth sweep):** two claims below are stale.
|
||||||
|
> (1) "there is no built-in cross-node replication today" — cross-node replication
|
||||||
|
> has since shipped: ScadaBridge adopted opt-in **SQL-Server hub replication** for
|
||||||
|
> the host secret store (commit `8e12f994`, "feat(secrets): opt-in SQL-Server hub
|
||||||
|
> replication for the host secret store"; `ZB.MOM.WW.Secrets` 0.2.x Replicator
|
||||||
|
> packages), covered by the shared library's clustered-secrets runbook
|
||||||
|
> (`scadaproj/ZB.MOM.WW.Secrets/docs/operations/clustered-secrets.md`).
|
||||||
|
> (2) the G-8 KEK-rotation runbook is no longer "not yet built" — it ships with the
|
||||||
|
> shared library at `scadaproj/ZB.MOM.WW.Secrets/docs/operations/kek-rotation.md`
|
||||||
|
> (lib 0.1.3, `Rewrap`/`rewrap-all`). The interim shared-volume posture below
|
||||||
|
> remains valid but is no longer the only option.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`ZB.MOM.WW.Secrets` resolves `${secret:...}` tokens in `appsettings.*.json` via a
|
`ZB.MOM.WW.Secrets` resolves `${secret:...}` tokens in `appsettings.*.json` via a
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Date**: 2026-03-16
|
**Date**: 2026-03-16
|
||||||
**Component**: Cluster Infrastructure (`docs/requirements/Component-ClusterInfrastructure.md`)
|
**Component**: Cluster Infrastructure (`docs/requirements/Component-ClusterInfrastructure.md`)
|
||||||
**Status**: Approved
|
**Status**: Approved — superseded in part: the keep-oldest SBR decision was replaced by the auto-down decision 2026-07-21 (`docs/plans/2026-07-21-auto-down-availability-decision.md`).
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Date**: 2026-03-16
|
**Date**: 2026-03-16
|
||||||
**Component**: Central–Site Communication (`docs/requirements/Component-Communication.md`)
|
**Component**: Central–Site Communication (`docs/requirements/Component-Communication.md`)
|
||||||
**Status**: Approved
|
**Status**: Approved — transport decisions superseded by the ClusterClient→gRPC migration (`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`); the "no buffering at central" decision still stands.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-03-17-deploy-artifacts-remove-configdb.md",
|
"planPath": "docs/plans/2026-03-17-deploy-artifacts-remove-configdb.md",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 4, "subject": "Task 1: Create SiteExternalSystemRepository", "status": "pending"},
|
{"id": 4, "subject": "Task 1: Create SiteExternalSystemRepository", "status": "completed"},
|
||||||
{"id": 5, "subject": "Task 2: Create SiteNotificationRepository", "status": "pending"},
|
{"id": 5, "subject": "Task 2: Create SiteNotificationRepository", "status": "completed", "note": "Shipped as planned, then deliberately removed 2026-07-10 (arch-review 08 §1.3 — excise vestigial site notification surface; notification delivery is central-only). The notification_lists / smtp_configurations tables survive on the site as deliberately-empty, unregistered (non-replicated) tables."},
|
||||||
{"id": 6, "subject": "Task 3: Add data connections to DeployArtifactsCommand", "status": "pending"},
|
{"id": 6, "subject": "Task 3: Add data connections to DeployArtifactsCommand", "status": "completed"},
|
||||||
{"id": 7, "subject": "Task 4: Wire site-local repositories into DI", "status": "pending", "blockedBy": [4, 5, 6]},
|
{"id": 7, "subject": "Task 4: Wire site-local repositories into DI", "status": "completed", "blockedBy": [4, 5, 6]},
|
||||||
{"id": 8, "subject": "Task 5: Add Deploy Artifacts button to Sites admin page", "status": "pending", "blockedBy": [9]},
|
{"id": 8, "subject": "Task 5: Add Deploy Artifacts button to Sites admin page", "status": "completed", "blockedBy": [9]},
|
||||||
{"id": 9, "subject": "Task 6: Update ArtifactDeploymentService to include all artifact types", "status": "pending"},
|
{"id": 9, "subject": "Task 6: Update ArtifactDeploymentService to include all artifact types", "status": "completed"},
|
||||||
{"id": 10, "subject": "Task 7: End-to-end test", "status": "pending", "blockedBy": [7, 8, 9]}
|
{"id": 10, "subject": "Task 7: End-to-end test", "status": "completed", "blockedBy": [7, 8, 9]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-03-17T16:40:00Z"
|
"lastUpdated": "2026-08-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-03-17-management-service-cli.md",
|
"planPath": "docs/plans/2026-03-17-management-service-cli.md",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 11, "subject": "Task 1: Create ManagementService project and test project", "status": "pending"},
|
{"id": 11, "subject": "Task 1: Create ManagementService project and test project", "status": "completed"},
|
||||||
{"id": 12, "subject": "Task 2: Define management message contracts in Commons", "status": "pending"},
|
{"id": 12, "subject": "Task 2: Define management message contracts in Commons", "status": "completed"},
|
||||||
{"id": 13, "subject": "Task 3: Implement ManagementActor", "status": "pending", "blockedBy": [11, 12]},
|
{"id": 13, "subject": "Task 3: Implement ManagementActor", "status": "completed", "blockedBy": [11, 12]},
|
||||||
{"id": 14, "subject": "Task 4: Register ManagementActor on Central with ClusterClientReceptionist", "status": "pending", "blockedBy": [13]},
|
{"id": 14, "subject": "Task 4: Register ManagementActor on Central with ClusterClientReceptionist", "status": "completed", "blockedBy": [13], "note": "OBSOLETE — never built; CLI shipped HTTP-only (ManagementHttpClient), receptionist deleted in the 2026-07-22 gRPC migration. Marked completed only to close the tracker."},
|
||||||
{"id": 15, "subject": "Task 5: Create CLI project with ClusterClient scaffolding", "status": "pending", "blockedBy": [12]},
|
{"id": 15, "subject": "Task 5: Create CLI project with ClusterClient scaffolding", "status": "completed", "blockedBy": [12], "note": "OBSOLETE — never built; CLI shipped HTTP-only (ManagementHttpClient), receptionist deleted in the 2026-07-22 gRPC migration. The CLI project itself exists; only the ClusterClient scaffolding is obsolete."},
|
||||||
{"id": 16, "subject": "Task 6: Implement CLI command groups", "status": "pending", "blockedBy": [15]},
|
{"id": 16, "subject": "Task 6: Implement CLI command groups", "status": "completed", "blockedBy": [15]},
|
||||||
{"id": 17, "subject": "Task 7: Write ManagementActor unit tests", "status": "pending", "blockedBy": [13]},
|
{"id": 17, "subject": "Task 7: Write ManagementActor unit tests", "status": "completed", "blockedBy": [13]},
|
||||||
{"id": 18, "subject": "Task 8: End-to-end integration test", "status": "pending", "blockedBy": [14, 16, 17]}
|
{"id": 18, "subject": "Task 8: End-to-end integration test", "status": "completed", "blockedBy": [14, 16, 17]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-03-17T17:00:00Z"
|
"lastUpdated": "2026-08-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-03-21-grpc-streaming-channel.md",
|
"planPath": "docs/plans/2026-03-21-grpc-streaming-channel.md",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code — feature shipped and later extended by the full ClusterClient→gRPC migration (docs/plans/2026-07-22-clusterclient-to-grpc-plan.md)",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 0, "taskId": "1", "subject": "Task 0: Proto Definition & Stub Generation", "status": "pending"},
|
{"id": 0, "taskId": "1", "subject": "Task 0: Proto Definition & Stub Generation", "status": "completed"},
|
||||||
{"id": 1, "taskId": "2", "subject": "Task 1: Site Config — GrpcPort in NodeOptions", "status": "pending", "blockedBy": [0]},
|
{"id": 1, "taskId": "2", "subject": "Task 1: Site Config — GrpcPort in NodeOptions", "status": "completed", "blockedBy": [0]},
|
||||||
{"id": 2, "taskId": "3", "subject": "Task 2: Site Entity — gRPC Address Fields", "status": "pending", "blockedBy": [0]},
|
{"id": 2, "taskId": "3", "subject": "Task 2: Site Entity — gRPC Address Fields", "status": "completed", "blockedBy": [0]},
|
||||||
{"id": 3, "taskId": "4", "subject": "Task 3: Site-Side gRPC Server — StreamRelayActor", "status": "pending", "blockedBy": [0]},
|
{"id": 3, "taskId": "4", "subject": "Task 3: Site-Side gRPC Server — StreamRelayActor", "status": "completed", "blockedBy": [0]},
|
||||||
{"id": 4, "taskId": "5", "subject": "Task 4: Site-Side gRPC Server — SiteStreamGrpcServer", "status": "pending", "blockedBy": [3]},
|
{"id": 4, "taskId": "5", "subject": "Task 4: Site-Side gRPC Server — SiteStreamGrpcServer", "status": "completed", "blockedBy": [3]},
|
||||||
{"id": 5, "taskId": "6", "subject": "Task 5: Switch Site Host to WebApplicationBuilder + gRPC", "status": "pending", "blockedBy": [4]},
|
{"id": 5, "taskId": "6", "subject": "Task 5: Switch Site Host to WebApplicationBuilder + gRPC", "status": "completed", "blockedBy": [4]},
|
||||||
{"id": 6, "taskId": "7", "subject": "Task 6: Central-Side gRPC Client", "status": "pending", "blockedBy": [0]},
|
{"id": 6, "taskId": "7", "subject": "Task 6: Central-Side gRPC Client", "status": "completed", "blockedBy": [0]},
|
||||||
{"id": 7, "taskId": "8", "subject": "Task 7: Update DebugStreamBridgeActor to Use gRPC", "status": "pending", "blockedBy": [6, 5]},
|
{"id": 7, "taskId": "8", "subject": "Task 7: Update DebugStreamBridgeActor to Use gRPC", "status": "completed", "blockedBy": [6, 5]},
|
||||||
{"id": 8, "taskId": "9", "subject": "Task 8: Remove ClusterClient Streaming Path", "status": "pending", "blockedBy": [7]},
|
{"id": 8, "taskId": "9", "subject": "Task 8: Remove ClusterClient Streaming Path", "status": "completed", "blockedBy": [7]},
|
||||||
{"id": 9, "taskId": "10", "subject": "Task 9: Docker & End-to-End Integration Test", "status": "pending", "blockedBy": [5, 1, 2]},
|
{"id": 9, "taskId": "10", "subject": "Task 9: Docker & End-to-End Integration Test", "status": "completed", "blockedBy": [5, 1, 2]},
|
||||||
{"id": 10, "taskId": "11", "subject": "Task 10: Documentation Updates", "status": "pending", "blockedBy": [9]},
|
{"id": 10, "taskId": "11", "subject": "Task 10: Documentation Updates", "status": "completed", "blockedBy": [9]},
|
||||||
{"id": 11, "taskId": "12", "subject": "Task 11: Final Guardrail Tests", "status": "pending", "blockedBy": [9]}
|
{"id": 11, "taskId": "12", "subject": "Task 11: Final Guardrail Tests", "status": "completed", "blockedBy": [9]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-03-21T14:15:00Z"
|
"lastUpdated": "2026-08-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-03-22-primary-backup-data-connections.md",
|
"planPath": "docs/plans/2026-03-22-primary-backup-data-connections.md",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code — shipped (EF migration AddPrimaryBackupDataConnections; failover coverage in DataConnectionActorTests)",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 1, "subject": "Task 1: Entity Model & Database Migration", "status": "pending"},
|
{"id": 1, "subject": "Task 1: Entity Model & Database Migration", "status": "completed", "note": "EF migration AddPrimaryBackupDataConnections."},
|
||||||
{"id": 2, "subject": "Task 2: Update CreateConnectionCommand & Manager Actor", "status": "pending", "blockedBy": [1]},
|
{"id": 2, "subject": "Task 2: Update CreateConnectionCommand & Manager Actor", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 3, "subject": "Task 3: DataConnectionActor Failover State Machine", "status": "pending", "blockedBy": [1, 2]},
|
{"id": 3, "subject": "Task 3: DataConnectionActor Failover State Machine", "status": "completed", "blockedBy": [1, 2]},
|
||||||
{"id": 4, "subject": "Task 4: Failover Tests", "status": "pending", "blockedBy": [3]},
|
{"id": 4, "subject": "Task 4: Failover Tests", "status": "completed", "blockedBy": [3], "note": "Covered by DataConnectionActorTests."},
|
||||||
{"id": 5, "subject": "Task 5: Health Reporting & Site Event Logging", "status": "pending", "blockedBy": [3]},
|
{"id": 5, "subject": "Task 5: Health Reporting & Site Event Logging", "status": "completed", "blockedBy": [3]},
|
||||||
{"id": 6, "subject": "Task 6: Central UI Changes", "status": "pending", "blockedBy": [1]},
|
{"id": 6, "subject": "Task 6: Central UI Changes", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 7, "subject": "Task 7: CLI, Management API, and Deployment", "status": "pending", "blockedBy": [1]},
|
{"id": 7, "subject": "Task 7: CLI, Management API, and Deployment", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 8, "subject": "Task 8: Documentation Updates", "status": "pending", "blockedBy": [3]}
|
{"id": 8, "subject": "Task 8: Documentation Updates", "status": "completed", "blockedBy": [3]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-03-22T12:00:00Z"
|
"lastUpdated": "2026-08-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-03-23-treeview-component.md",
|
"planPath": "docs/plans/2026-03-23-treeview-component.md",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 22, "subject": "Task 1: Create TreeView.razor — Core Rendering (R1-R4, R14)", "status": "pending"},
|
{"id": 22, "subject": "Task 1: Create TreeView.razor — Core Rendering (R1-R4, R14)", "status": "completed"},
|
||||||
{"id": 23, "subject": "Task 2: Add Selection Support (R5)", "status": "pending", "blockedBy": [22]},
|
{"id": 23, "subject": "Task 2: Add Selection Support (R5)", "status": "completed", "blockedBy": [22]},
|
||||||
{"id": 24, "subject": "Task 3: Add Session Storage Persistence (R11)", "status": "pending", "blockedBy": [23]},
|
{"id": 24, "subject": "Task 3: Add Session Storage Persistence (R11)", "status": "completed", "blockedBy": [23]},
|
||||||
{"id": 25, "subject": "Task 4: Add ExpandAll, CollapseAll, RevealNode (R12, R13)", "status": "pending", "blockedBy": [24]},
|
{"id": 25, "subject": "Task 4: Add ExpandAll, CollapseAll, RevealNode (R12, R13)", "status": "completed", "blockedBy": [24]},
|
||||||
{"id": 26, "subject": "Task 5: Add Context Menu (R15)", "status": "pending", "blockedBy": [25]},
|
{"id": 26, "subject": "Task 5: Add Context Menu (R15)", "status": "completed", "blockedBy": [25]},
|
||||||
{"id": 27, "subject": "Task 6: Add External Filtering Tests (R8)", "status": "pending", "blockedBy": [26]},
|
{"id": 27, "subject": "Task 6: Add External Filtering Tests (R8)", "status": "completed", "blockedBy": [26]},
|
||||||
{"id": 28, "subject": "Task 7: Integrate TreeView into Data Connections Page", "status": "pending", "blockedBy": [27]},
|
{"id": 28, "subject": "Task 7: Integrate TreeView into Data Connections Page", "status": "completed", "blockedBy": [27]},
|
||||||
{"id": 29, "subject": "Task 8: Integrate TreeView into Areas Page", "status": "pending", "blockedBy": [27]},
|
{"id": 29, "subject": "Task 8: Integrate TreeView into Areas Page", "status": "completed", "blockedBy": [27], "note": "OBSOLETE — those pages never existed; Site/Area/Instance render in one tree on Pages/Deployment/Topology.razor (2026-05-11 topology design). Marked completed only to close the tracker."},
|
||||||
{"id": 30, "subject": "Task 9: Integrate TreeView into Instances Page", "status": "pending", "blockedBy": [27]},
|
{"id": 30, "subject": "Task 9: Integrate TreeView into Instances Page", "status": "completed", "blockedBy": [27], "note": "OBSOLETE — those pages never existed; Site/Area/Instance render in one tree on Pages/Deployment/Topology.razor (2026-05-11 topology design). Marked completed only to close the tracker."},
|
||||||
{"id": 31, "subject": "Task 10: Full Build Verification", "status": "pending", "blockedBy": [28, 29, 30]}
|
{"id": 31, "subject": "Task 10: Full Build Verification", "status": "completed", "blockedBy": [28, 29, 30]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-03-23T00:00:00Z"
|
"lastUpdated": "2026-08-01T00:00:00Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,9 +138,9 @@ Areas can be moved freely (subject to validation). Templates are different becau
|
|||||||
- `AreaService.UpdateAreaAsync` (stays name-only)
|
- `AreaService.UpdateAreaAsync` (stays name-only)
|
||||||
- `InstanceService` lifecycle methods (already used by current Instances page)
|
- `InstanceService` lifecycle methods (already used by current Instances page)
|
||||||
|
|
||||||
### CLI / ManagementService parity (optional follow-up)
|
### CLI / ManagementService parity (optional follow-up) — **DONE 2026-08-01**
|
||||||
- Add `MoveAreaCommand` message + `ManagementService` handler that wraps `MoveAreaAsync`.
|
- ~~Add `MoveAreaCommand` message + `ManagementService` handler that wraps `MoveAreaAsync`.~~ Shipped: `MoveAreaCommand(int AreaId, int? NewParentAreaId)` in `Commons/Messages/Management/SiteCommands.cs`, dispatched by `ManagementActor.HandleMoveArea` (delegates to `AreaService.MoveAreaAsync`; failures surface as the standard curated failure response), gated any-of `[Designer, Deployer]` like the other area mutations.
|
||||||
- Add CLI: `cli area move --id X --parent-id Y --username … --password …` (omit `--parent-id` to move to site root).
|
- ~~Add CLI: `cli area move --id X --parent-id Y …` (omit `--parent-id` to move to site root).~~ Shipped as **`scadabridge site area move --id X [--parent-id Y]`** — the area verbs live under the existing `site area` group, not at the CLI root, so the verb was placed alongside `site area create|update|delete` rather than introducing a second top-level spelling.
|
||||||
|
|
||||||
Not strictly required to ship the UI page, but worth doing for parity with how the rest of the app exposes admin ops.
|
Not strictly required to ship the UI page, but worth doing for parity with how the rest of the app exposes admin ops.
|
||||||
|
|
||||||
|
|||||||
@@ -2180,6 +2180,15 @@ Expected: image rebuilds, 5-container cluster starts.
|
|||||||
|
|
||||||
**Step 3: Manual smoke checklist**
|
**Step 3: Manual smoke checklist**
|
||||||
|
|
||||||
|
> **RETIRED 2026-08-01 — never run; unrunnable as written and superseded by delivered coverage.**
|
||||||
|
> Two steps (drag a template onto "Dev"; drag "Dev" onto "Sub") reference folder drag-drop, which was
|
||||||
|
> **permanently deferred** by the M9 decision (menu-based reorder shipped instead — see the deferred-work
|
||||||
|
> register's Resolved row 18), so the checklist cannot pass as written. The remaining behaviors are
|
||||||
|
> covered by the CentralUI bUnit suites (folder CRUD/context menus/cycle guard/TreeView reveal +
|
||||||
|
> sessionStorage expansion) delivered through M9/M10, and the management folder commands gained full CLI
|
||||||
|
> parity on 2026-08-01 (`template folder list|create|rename|move|reorder|delete`, commit `88638d77`).
|
||||||
|
> Do not run or refresh this checklist.
|
||||||
|
|
||||||
Open http://localhost:9000/design/templates (login `multi-role` / `password`). Verify:
|
Open http://localhost:9000/design/templates (login `multi-role` / `password`). Verify:
|
||||||
|
|
||||||
- [ ] Existing templates appear at root (no folder).
|
- [ ] Existing templates appear at root (no folder).
|
||||||
@@ -2202,7 +2211,7 @@ Open http://localhost:9000/design/templates (login `multi-role` / `password`). V
|
|||||||
|
|
||||||
## Out of scope (per design)
|
## Out of scope (per design)
|
||||||
|
|
||||||
- CLI commands for folder operations (Management Service contracts now exist; CLI follows in a future plan).
|
- ~~CLI commands for folder operations (Management Service contracts now exist; CLI follows in a future plan).~~ **DONE 2026-08-01** — shipped as `scadabridge template folder list|create|rename|move|reorder|delete`, mapping 1:1 onto `ListTemplateFolders` / `CreateTemplateFolder` / `RenameTemplateFolder` / `MoveTemplateFolder` / `ReorderTemplateFolder` / `DeleteTemplateFolder`. Omitting `--parent-id` on create/move targets the tree root; `reorder --direction` takes the lowercase literals `up` / `down`.
|
||||||
- Tree search / filter input.
|
- Tree search / filter input.
|
||||||
- Sibling reordering via drag-drop (alphabetical sort is fixed).
|
- Sibling reordering via drag-drop (alphabetical sort is fixed).
|
||||||
- Root-area context menu (right-click in empty tree space).
|
- Root-area context menu (right-click in empty tree space).
|
||||||
|
|||||||
@@ -1,29 +1,30 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-05-11-templates-folder-hierarchy.md",
|
"planPath": "docs/plans/2026-05-11-templates-folder-hierarchy.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 7, "subject": "Task 0: Confirm baseline + create work branch", "status": "pending"},
|
{"id": 7, "subject": "Task 0: Confirm baseline + create work branch", "status": "completed"},
|
||||||
{"id": 8, "subject": "Task 1: Add TemplateFolder entity + Template.FolderId", "status": "pending", "blockedBy": [7]},
|
{"id": 8, "subject": "Task 1: Add TemplateFolder entity + Template.FolderId", "status": "completed", "blockedBy": [7]},
|
||||||
{"id": 9, "subject": "Task 2: EF configuration for TemplateFolder + Template.FolderId", "status": "pending", "blockedBy": [8]},
|
{"id": 9, "subject": "Task 2: EF configuration for TemplateFolder + Template.FolderId", "status": "completed", "blockedBy": [8]},
|
||||||
{"id": 10, "subject": "Task 3: Generate EF migration AddTemplateFolders", "status": "pending", "blockedBy": [9]},
|
{"id": 10, "subject": "Task 3: Generate EF migration AddTemplateFolders", "status": "completed", "blockedBy": [9]},
|
||||||
{"id": 11, "subject": "Task 4: Repository methods for TemplateFolder", "status": "pending", "blockedBy": [10]},
|
{"id": 11, "subject": "Task 4: Repository methods for TemplateFolder", "status": "completed", "blockedBy": [10]},
|
||||||
{"id": 12, "subject": "Task 5: TemplateFolderService.CreateFolderAsync (TDD)", "status": "pending", "blockedBy": [11]},
|
{"id": 12, "subject": "Task 5: TemplateFolderService.CreateFolderAsync (TDD)", "status": "completed", "blockedBy": [11]},
|
||||||
{"id": 13, "subject": "Task 6: TemplateFolderService.RenameFolderAsync", "status": "pending", "blockedBy": [12]},
|
{"id": 13, "subject": "Task 6: TemplateFolderService.RenameFolderAsync", "status": "completed", "blockedBy": [12]},
|
||||||
{"id": 14, "subject": "Task 7: TemplateFolderService.MoveFolderAsync with cycle detection", "status": "pending", "blockedBy": [13]},
|
{"id": 14, "subject": "Task 7: TemplateFolderService.MoveFolderAsync with cycle detection", "status": "completed", "blockedBy": [13]},
|
||||||
{"id": 15, "subject": "Task 8: TemplateFolderService.DeleteFolderAsync (non-empty check)", "status": "pending", "blockedBy": [14]},
|
{"id": 15, "subject": "Task 8: TemplateFolderService.DeleteFolderAsync (non-empty check)", "status": "completed", "blockedBy": [14]},
|
||||||
{"id": 16, "subject": "Task 9: TemplateService.MoveTemplateAsync", "status": "pending", "blockedBy": [11]},
|
{"id": 16, "subject": "Task 9: TemplateService.MoveTemplateAsync", "status": "completed", "blockedBy": [11]},
|
||||||
{"id": 17, "subject": "Task 10: DI registration for TemplateFolderService", "status": "pending", "blockedBy": [15, 16]},
|
{"id": 17, "subject": "Task 10: DI registration for TemplateFolderService", "status": "completed", "blockedBy": [15, 16]},
|
||||||
{"id": 18, "subject": "Task 11: Management command records for TemplateFolder", "status": "pending", "blockedBy": [17]},
|
{"id": 18, "subject": "Task 11: Management command records for TemplateFolder", "status": "completed", "blockedBy": [17]},
|
||||||
{"id": 19, "subject": "Task 12: ManagementActor authorization + handlers", "status": "pending", "blockedBy": [18]},
|
{"id": 19, "subject": "Task 12: ManagementActor authorization + handlers", "status": "completed", "blockedBy": [18]},
|
||||||
{"id": 20, "subject": "Task 13: Templates.razor — load folders alongside templates", "status": "pending", "blockedBy": [17]},
|
{"id": 20, "subject": "Task 13: Templates.razor — load folders alongside templates", "status": "completed", "blockedBy": [17]},
|
||||||
{"id": 21, "subject": "Task 14: Build new TmplNode tree model", "status": "pending", "blockedBy": [20]},
|
{"id": 21, "subject": "Task 14: Build new TmplNode tree model", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 22, "subject": "Task 15: Split-pane layout + new TreeView wiring", "status": "pending", "blockedBy": [21]},
|
{"id": 22, "subject": "Task 15: Split-pane layout + new TreeView wiring", "status": "completed", "blockedBy": [21]},
|
||||||
{"id": 23, "subject": "Task 16: Per-kind context menus", "status": "pending", "blockedBy": [22]},
|
{"id": 23, "subject": "Task 16: Per-kind context menus", "status": "completed", "blockedBy": [22]},
|
||||||
{"id": 24, "subject": "Task 17: New-folder, new-template, move-template dialogs", "status": "pending", "blockedBy": [23]},
|
{"id": 24, "subject": "Task 17: New-folder, new-template, move-template dialogs", "status": "completed", "blockedBy": [23]},
|
||||||
{"id": 25, "subject": "Task 18: Drag-drop reorganization", "status": "pending", "blockedBy": [24]},
|
{"id": 25, "subject": "Task 18: Drag-drop reorganization", "status": "completed", "blockedBy": [24], "notes": "DROPPED — superseded by M9 menu-based reorder (commits e3bc19c6/314c7dea) + MoveFolderDialog; no DnD anywhere in CentralUI"},
|
||||||
{"id": 26, "subject": "Task 19: Deep-link reveal on load", "status": "pending", "blockedBy": [22]},
|
{"id": 26, "subject": "Task 19: Deep-link reveal on load", "status": "completed", "blockedBy": [22], "notes": "OBSOLETE — /design/templates/{id} is TemplateEdit's own route; two-page split makes in-tree reveal moot"},
|
||||||
{"id": 27, "subject": "Task 20: bUnit tests for the new page", "status": "pending", "blockedBy": [22]},
|
{"id": 27, "subject": "Task 20: bUnit tests for the new page", "status": "completed", "blockedBy": [22]},
|
||||||
{"id": 28, "subject": "Task 21: Documentation updates", "status": "pending", "blockedBy": [25, 26, 27]},
|
{"id": 28, "subject": "Task 21: Documentation updates", "status": "completed", "blockedBy": [25, 26, 27]},
|
||||||
{"id": 29, "subject": "Task 22: Final smoke + green-suite check", "status": "pending", "blockedBy": [25, 26, 27, 28]}
|
{"id": 29, "subject": "Task 22: Final smoke + green-suite check", "status": "completed", "blockedBy": [25, 26, 27, 28]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-05-11"
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||||
|
"lastUpdated": "2026-08-01"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Derive-on-compose: implementation status
|
# Derive-on-compose: implementation status
|
||||||
|
|
||||||
|
> **2026-08-01:** The "Still to verify" items below are satisfied — the
|
||||||
|
> Phase-3 migration ships and auto-applies, and
|
||||||
|
> `2026-05-18-contained-template-names-design.md` records it applied and
|
||||||
|
> browser-verified on the dev cluster. The "How to resume" section is
|
||||||
|
> historical.
|
||||||
|
|
||||||
> **For Claude resuming later:** All nine phases are implemented. This
|
> **For Claude resuming later:** All nine phases are implemented. This
|
||||||
> file is the change-record for the work, not a plan. See the companion
|
> file is the change-record for the work, not a plan. See the companion
|
||||||
> design doc `2026-05-12-derive-on-compose-design.md` for rationale.
|
> design doc `2026-05-12-derive-on-compose-design.md` for rationale.
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-05-12-opcua-config-model.md",
|
"planPath": "docs/plans/2026-05-12-opcua-config-model.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 45, "subject": "Task 1: Create OPC UA config POCOs + ValidationCategory.ConnectionConfig", "status": "pending"},
|
{"id": 45, "subject": "Task 1: Create OPC UA config POCOs + ValidationCategory.ConnectionConfig", "status": "completed"},
|
||||||
{"id": 46, "subject": "Task 2: TDD failing tests for OpcUaEndpointConfigSerializer", "status": "pending", "blockedBy": [45]},
|
{"id": 46, "subject": "Task 2: TDD failing tests for OpcUaEndpointConfigSerializer", "status": "completed", "blockedBy": [45]},
|
||||||
{"id": 47, "subject": "Task 3: Implement OpcUaEndpointConfigSerializer", "status": "pending", "blockedBy": [46]},
|
{"id": 47, "subject": "Task 3: Implement OpcUaEndpointConfigSerializer", "status": "completed", "blockedBy": [46]},
|
||||||
{"id": 48, "subject": "Task 4: TDD failing tests for OpcUaEndpointConfigValidator", "status": "pending", "blockedBy": [45]},
|
{"id": 48, "subject": "Task 4: TDD failing tests for OpcUaEndpointConfigValidator", "status": "completed", "blockedBy": [45]},
|
||||||
{"id": 49, "subject": "Task 5: Implement OpcUaEndpointConfigValidator", "status": "pending", "blockedBy": [48]},
|
{"id": 49, "subject": "Task 5: Implement OpcUaEndpointConfigValidator", "status": "completed", "blockedBy": [48]},
|
||||||
{"id": 50, "subject": "Task 6: Refactor OpcUaDataConnection.ConnectAsync to use FromFlatDict", "status": "pending", "blockedBy": [47]},
|
{"id": 50, "subject": "Task 6: Refactor OpcUaDataConnection.ConnectAsync to use FromFlatDict", "status": "completed", "blockedBy": [47]},
|
||||||
{"id": 51, "subject": "Task 7: Refactor DeploymentManagerActor.EnsureDclConnections", "status": "pending", "blockedBy": [47]},
|
{"id": 51, "subject": "Task 7: Refactor DeploymentManagerActor.EnsureDclConnections", "status": "completed", "blockedBy": [47]},
|
||||||
{"id": 52, "subject": "Task 8: TDD failing bUnit tests for OpcUaEndpointEditor", "status": "pending", "blockedBy": [45, 49]},
|
{"id": 52, "subject": "Task 8: TDD failing bUnit tests for OpcUaEndpointEditor", "status": "completed", "blockedBy": [45, 49]},
|
||||||
{"id": 53, "subject": "Task 9: Implement OpcUaEndpointEditor.razor", "status": "pending", "blockedBy": [52]},
|
{"id": 53, "subject": "Task 9: Implement OpcUaEndpointEditor.razor", "status": "completed", "blockedBy": [52]},
|
||||||
{"id": 54, "subject": "Task 10: TDD failing bUnit tests for DataConnectionForm refactor", "status": "pending", "blockedBy": [47, 49]},
|
{"id": 54, "subject": "Task 10: TDD failing bUnit tests for DataConnectionForm refactor", "status": "completed", "blockedBy": [47, 49]},
|
||||||
{"id": 55, "subject": "Task 11: Refactor DataConnectionForm.razor", "status": "pending", "blockedBy": [53, 54]},
|
{"id": 55, "subject": "Task 11: Refactor DataConnectionForm.razor", "status": "completed", "blockedBy": [53, 54]},
|
||||||
{"id": 56, "subject": "Task 12: Solution build + all test suites green", "status": "pending", "blockedBy": [50, 51, 55]},
|
{"id": 56, "subject": "Task 12: Solution build + all test suites green", "status": "completed", "blockedBy": [50, 51, 55]},
|
||||||
{"id": 57, "subject": "Task 13: Docker deploy + browser smoke", "status": "pending", "blockedBy": [56]},
|
{"id": 57, "subject": "Task 13: Docker deploy + browser smoke", "status": "completed", "blockedBy": [56]},
|
||||||
{"id": 58, "subject": "Task 14: Push to origin", "status": "pending", "blockedBy": [57]}
|
{"id": 58, "subject": "Task 14: Push to origin", "status": "completed", "blockedBy": [57]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-05-12T04:33:33Z"
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||||
|
"lastUpdated": "2026-08-01"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
**Scope:** All Razor pages, layout, and shared components in `src/ZB.MOM.WW.ScadaBridge.CentralUI`.
|
**Scope:** All Razor pages, layout, and shared components in `src/ZB.MOM.WW.ScadaBridge.CentralUI`.
|
||||||
**Reference pattern:** `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Sites.razor` — 2-column responsive card grid, header flex row, kebab menus, search filter, Bootstrap collapse for noisy details, `@key=` on iterated cards, "No X match the filter." and empty-state CTAs.
|
**Reference pattern:** `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Sites.razor` — 2-column responsive card grid, header flex row, kebab menus, search filter, Bootstrap collapse for noisy details, `@key=` on iterated cards, "No X match the filter." and empty-state CTAs.
|
||||||
|
|
||||||
|
> **2026-08-01: fully superseded** — every cross-cutting recommendation was either shipped or explicitly scoped into the M10 UI/UX platform plan (`2026-06-18`) and KPI History (#26); retained for reference only.
|
||||||
|
|
||||||
## Constraints (recap)
|
## Constraints (recap)
|
||||||
|
|
||||||
- Blazor Server + Bootstrap 5 only. **No third-party component frameworks** (no MudBlazor / Radzen / Blazorise / Syncfusion).
|
- Blazor Server + Bootstrap 5 only. **No third-party component frameworks** (no MudBlazor / Radzen / Blazorise / Syncfusion).
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-05-16-expression-trigger.md",
|
"planPath": "docs/plans/2026-05-16-expression-trigger.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 25, "subject": "Task 1: Trigger model + codecs", "status": "pending"},
|
{"id": 25, "subject": "Task 1: Trigger model + codecs", "status": "completed"},
|
||||||
{"id": 26, "subject": "Task 2: Runtime expression evaluation", "status": "pending", "blockedBy": [25]},
|
{"id": 26, "subject": "Task 2: Runtime expression evaluation", "status": "completed", "blockedBy": [25]},
|
||||||
{"id": 27, "subject": "Task 3: Trigger editor panels", "status": "pending", "blockedBy": [25]},
|
{"id": 27, "subject": "Task 3: Trigger editor panels", "status": "completed", "blockedBy": [25]},
|
||||||
{"id": 28, "subject": "Task 4: Pre-deployment validation", "status": "pending", "blockedBy": [25, 26]},
|
{"id": 28, "subject": "Task 4: Pre-deployment validation", "status": "completed", "blockedBy": [25, 26]},
|
||||||
{"id": 29, "subject": "Task 5: Build, deploy, verify", "status": "pending", "blockedBy": [25, 26, 27, 28]}
|
{"id": 29, "subject": "Task 5: Build, deploy, verify", "status": "completed", "blockedBy": [25, 26, 27, 28]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-05-16"
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||||
|
"lastUpdated": "2026-08-01"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-05-18-notification-outbox.md",
|
"planPath": "docs/plans/2026-05-18-notification-outbox.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 7, "subject": "Task 1: Create Component-NotificationOutbox.md", "status": "pending"},
|
{"id": 7, "subject": "Task 1: Create Component-NotificationOutbox.md", "status": "completed"},
|
||||||
{"id": 8, "subject": "Task 2: Revise Component-NotificationService.md", "status": "pending", "blockedBy": [7]},
|
{"id": 8, "subject": "Task 2: Revise Component-NotificationService.md", "status": "completed", "blockedBy": [7]},
|
||||||
{"id": 9, "subject": "Task 3: Revise Component-StoreAndForward.md", "status": "pending", "blockedBy": [7]},
|
{"id": 9, "subject": "Task 3: Revise Component-StoreAndForward.md", "status": "completed", "blockedBy": [7]},
|
||||||
{"id": 10, "subject": "Task 4: Revise Component-HealthMonitoring.md", "status": "pending", "blockedBy": [7]},
|
{"id": 10, "subject": "Task 4: Revise Component-HealthMonitoring.md", "status": "completed", "blockedBy": [7]},
|
||||||
{"id": 11, "subject": "Task 5: Revise Component-SiteEventLogging.md", "status": "pending", "blockedBy": [7]},
|
{"id": 11, "subject": "Task 5: Revise Component-SiteEventLogging.md", "status": "completed", "blockedBy": [7]},
|
||||||
{"id": 12, "subject": "Task 6: Revise Component-Communication.md", "status": "pending", "blockedBy": [7]},
|
{"id": 12, "subject": "Task 6: Revise Component-Communication.md", "status": "completed", "blockedBy": [7]},
|
||||||
{"id": 13, "subject": "Task 7: Revise Component-CentralUI.md", "status": "pending", "blockedBy": [7]},
|
{"id": 13, "subject": "Task 7: Revise Component-CentralUI.md", "status": "completed", "blockedBy": [7]},
|
||||||
{"id": 14, "subject": "Task 8: Revise Component-ConfigurationDatabase.md and Component-Commons.md", "status": "pending", "blockedBy": [7]},
|
{"id": 14, "subject": "Task 8: Revise Component-ConfigurationDatabase.md and Component-Commons.md", "status": "completed", "blockedBy": [7]},
|
||||||
{"id": 15, "subject": "Task 9: Update README.md", "status": "pending", "blockedBy": [7, 8, 9, 10, 11, 12, 13, 14]},
|
{"id": 15, "subject": "Task 9: Update README.md", "status": "completed", "blockedBy": [7, 8, 9, 10, 11, 12, 13, 14]},
|
||||||
{"id": 16, "subject": "Task 10: Update CLAUDE.md", "status": "pending", "blockedBy": [7, 8, 9, 10, 11, 12, 13, 14]},
|
{"id": 16, "subject": "Task 10: Update CLAUDE.md", "status": "completed", "blockedBy": [7, 8, 9, 10, 11, 12, 13, 14]},
|
||||||
{"id": 17, "subject": "Task 11: Cross-reference consistency sweep", "status": "pending", "blockedBy": [7, 8, 9, 10, 11, 12, 13, 14, 15, 16]}
|
{"id": 17, "subject": "Task 11: Cross-reference consistency sweep", "status": "completed", "blockedBy": [7, 8, 9, 10, 11, 12, 13, 14, 15, 16]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-05-18"
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||||
|
"lastUpdated": "2026-08-01"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-05-19-cached-call-tracking.md",
|
"planPath": "docs/plans/2026-05-19-cached-call-tracking.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 6, "subject": "Task 1: Create Site Call Audit component doc", "status": "pending"},
|
{"id": 6, "subject": "Task 1: Create Site Call Audit component doc", "status": "completed"},
|
||||||
{"id": 7, "subject": "Task 2: Add tracking contracts to Commons", "status": "pending", "blockedBy": [6]},
|
{"id": 7, "subject": "Task 2: Add tracking contracts to Commons", "status": "completed", "blockedBy": [6]},
|
||||||
{"id": 8, "subject": "Task 3: Update Store-and-Forward doc", "status": "pending", "blockedBy": [6, 7]},
|
{"id": 8, "subject": "Task 3: Update Store-and-Forward doc", "status": "completed", "blockedBy": [6, 7]},
|
||||||
{"id": 9, "subject": "Task 4: Update External System Gateway doc", "status": "pending", "blockedBy": [6, 7]},
|
{"id": 9, "subject": "Task 4: Update External System Gateway doc", "status": "completed", "blockedBy": [6, 7]},
|
||||||
{"id": 10, "subject": "Task 5: Update Site Runtime Script Runtime API", "status": "pending", "blockedBy": [6, 7]},
|
{"id": 10, "subject": "Task 5: Update Site Runtime Script Runtime API", "status": "completed", "blockedBy": [6, 7]},
|
||||||
{"id": 11, "subject": "Task 6: Update Communication doc", "status": "pending", "blockedBy": [6, 7]},
|
{"id": 11, "subject": "Task 6: Update Communication doc", "status": "completed", "blockedBy": [6, 7]},
|
||||||
{"id": 12, "subject": "Task 7: Update Configuration Database doc", "status": "pending", "blockedBy": [6, 7]},
|
{"id": 12, "subject": "Task 7: Update Configuration Database doc", "status": "completed", "blockedBy": [6, 7]},
|
||||||
{"id": 13, "subject": "Task 8: Update Central UI doc", "status": "pending", "blockedBy": [6, 7]},
|
{"id": 13, "subject": "Task 8: Update Central UI doc", "status": "completed", "blockedBy": [6, 7]},
|
||||||
{"id": 14, "subject": "Task 9: Update Health Monitoring doc", "status": "pending", "blockedBy": [6, 7]},
|
{"id": 14, "subject": "Task 9: Update Health Monitoring doc", "status": "completed", "blockedBy": [6, 7]},
|
||||||
{"id": 15, "subject": "Task 10: Note shared model in notification docs", "status": "pending", "blockedBy": [6, 7]},
|
{"id": 15, "subject": "Task 10: Note shared model in notification docs", "status": "completed", "blockedBy": [6, 7]},
|
||||||
{"id": 16, "subject": "Task 11: Update README component table", "status": "pending", "blockedBy": [6]},
|
{"id": 16, "subject": "Task 11: Update README component table", "status": "completed", "blockedBy": [6]},
|
||||||
{"id": 17, "subject": "Task 12: Update CLAUDE.md", "status": "pending", "blockedBy": [6]},
|
{"id": 17, "subject": "Task 12: Update CLAUDE.md", "status": "completed", "blockedBy": [6]},
|
||||||
{"id": 18, "subject": "Task 13: Final cross-reference consistency pass", "status": "pending", "blockedBy": [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]}
|
{"id": 18, "subject": "Task 13: Final cross-reference consistency pass", "status": "completed", "blockedBy": [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-05-19"
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||||
|
"lastUpdated": "2026-08-01"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,33 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-05-19-notification-outbox-implementation.md",
|
"planPath": "docs/plans/2026-05-19-notification-outbox-implementation.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 18, "subject": "Task 1: Notification enums", "status": "pending"},
|
{"id": 18, "subject": "Task 1: Notification enums", "status": "completed"},
|
||||||
{"id": 19, "subject": "Task 2: Notification entity POCO", "status": "pending", "blockedBy": [18]},
|
{"id": 19, "subject": "Task 2: Notification entity POCO", "status": "completed", "blockedBy": [18]},
|
||||||
{"id": 20, "subject": "Task 3: Type field on NotificationList", "status": "pending", "blockedBy": [19]},
|
{"id": 20, "subject": "Task 3: Type field on NotificationList", "status": "completed", "blockedBy": [19]},
|
||||||
{"id": 21, "subject": "Task 4: Notification EF configuration + DbSet", "status": "pending", "blockedBy": [20]},
|
{"id": 21, "subject": "Task 4: Notification EF configuration + DbSet", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 22, "subject": "Task 5: NotificationOutbox repository", "status": "pending", "blockedBy": [21]},
|
{"id": 22, "subject": "Task 5: NotificationOutbox repository", "status": "completed", "blockedBy": [21]},
|
||||||
{"id": 23, "subject": "Task 6: EF migration AddNotificationsTable", "status": "pending", "blockedBy": [22]},
|
{"id": 23, "subject": "Task 6: EF migration AddNotificationsTable", "status": "completed", "blockedBy": [22]},
|
||||||
{"id": 24, "subject": "Task 7: Site/central notification message contracts", "status": "pending", "blockedBy": [23]},
|
{"id": 24, "subject": "Task 7: Site/central notification message contracts", "status": "completed", "blockedBy": [23]},
|
||||||
{"id": 25, "subject": "Task 8: Outbox query/action contracts", "status": "pending", "blockedBy": [24]},
|
{"id": 25, "subject": "Task 8: Outbox query/action contracts", "status": "completed", "blockedBy": [24]},
|
||||||
{"id": 26, "subject": "Task 9: Scaffold ZB.MOM.WW.ScadaBridge.NotificationOutbox project", "status": "pending", "blockedBy": [25]},
|
{"id": 26, "subject": "Task 9: Scaffold ZB.MOM.WW.ScadaBridge.NotificationOutbox project", "status": "completed", "blockedBy": [25]},
|
||||||
{"id": 27, "subject": "Task 10: NotificationOutboxOptions", "status": "pending", "blockedBy": [26]},
|
{"id": 27, "subject": "Task 10: NotificationOutboxOptions", "status": "completed", "blockedBy": [26]},
|
||||||
{"id": 28, "subject": "Task 11: Delivery adapter abstraction", "status": "pending", "blockedBy": [27]},
|
{"id": 28, "subject": "Task 11: Delivery adapter abstraction", "status": "completed", "blockedBy": [27]},
|
||||||
{"id": 29, "subject": "Task 12: Email delivery adapter", "status": "pending", "blockedBy": [28]},
|
{"id": 29, "subject": "Task 12: Email delivery adapter", "status": "completed", "blockedBy": [28]},
|
||||||
{"id": 30, "subject": "Task 13: NotificationOutboxActor ingest", "status": "pending", "blockedBy": [29]},
|
{"id": 30, "subject": "Task 13: NotificationOutboxActor ingest", "status": "completed", "blockedBy": [29]},
|
||||||
{"id": 31, "subject": "Task 14: Dispatcher loop", "status": "pending", "blockedBy": [30]},
|
{"id": 31, "subject": "Task 14: Dispatcher loop", "status": "completed", "blockedBy": [30]},
|
||||||
{"id": 32, "subject": "Task 15: Query, retry, discard, KPI handlers", "status": "pending", "blockedBy": [31]},
|
{"id": 32, "subject": "Task 15: Query, retry, discard, KPI handlers", "status": "completed", "blockedBy": [31]},
|
||||||
{"id": 33, "subject": "Task 16: Daily purge job", "status": "pending", "blockedBy": [32]},
|
{"id": 33, "subject": "Task 16: Daily purge job", "status": "completed", "blockedBy": [32]},
|
||||||
{"id": 34, "subject": "Task 17: AddNotificationOutbox DI extension", "status": "pending", "blockedBy": [33]},
|
{"id": 34, "subject": "Task 17: AddNotificationOutbox DI extension", "status": "completed", "blockedBy": [33]},
|
||||||
{"id": 35, "subject": "Task 18: Retarget site S&F notification handler to central", "status": "pending", "blockedBy": [34]},
|
{"id": 35, "subject": "Task 18: Retarget site S&F notification handler to central", "status": "completed", "blockedBy": [34]},
|
||||||
{"id": 36, "subject": "Task 19: Async Notify.Send + Notify.Status", "status": "pending", "blockedBy": [35]},
|
{"id": 36, "subject": "Task 19: Async Notify.Send + Notify.Status", "status": "completed", "blockedBy": [35]},
|
||||||
{"id": 37, "subject": "Task 20: Central ingest routing", "status": "pending", "blockedBy": [36]},
|
{"id": 37, "subject": "Task 20: Central ingest routing", "status": "completed", "blockedBy": [36]},
|
||||||
{"id": 38, "subject": "Task 21: Host registration + appsettings", "status": "pending", "blockedBy": [37]},
|
{"id": 38, "subject": "Task 21: Host registration + appsettings", "status": "completed", "blockedBy": [37]},
|
||||||
{"id": 39, "subject": "Task 22: CommunicationService outbox methods", "status": "pending", "blockedBy": [38]},
|
{"id": 39, "subject": "Task 22: CommunicationService outbox methods", "status": "completed", "blockedBy": [38]},
|
||||||
{"id": 40, "subject": "Task 23: Notification Outbox Blazor page", "status": "pending", "blockedBy": [39]},
|
{"id": 40, "subject": "Task 23: Notification Outbox Blazor page", "status": "completed", "blockedBy": [39]},
|
||||||
{"id": 41, "subject": "Task 24: Health dashboard outbox KPI tiles", "status": "pending", "blockedBy": [40]},
|
{"id": 41, "subject": "Task 24: Health dashboard outbox KPI tiles", "status": "completed", "blockedBy": [40]},
|
||||||
{"id": 42, "subject": "Task 25: End-to-end integration test", "status": "pending", "blockedBy": [41]},
|
{"id": 42, "subject": "Task 25: End-to-end integration test", "status": "completed", "blockedBy": [41]},
|
||||||
{"id": 43, "subject": "Task 26: Full build + suite verification", "status": "pending", "blockedBy": [42]}
|
{"id": 43, "subject": "Task 26: Full build + suite verification", "status": "completed", "blockedBy": [42]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-05-19"
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||||
|
"lastUpdated": "2026-08-01"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
> for site roles, with `NoOpSiteStreamAuditClient` retained only for central/test
|
> for site roles, with `NoOpSiteStreamAuditClient` retained only for central/test
|
||||||
> composition roots; and `AuditLogQueryFilter` is now multi-value per dimension.
|
> composition roots; and `AuditLogQueryFilter` is now multi-value per dimension.
|
||||||
>
|
>
|
||||||
|
> **Transport wording below is stale:** the ClusterClient-based push described throughout this roadmap was replaced by gRPC in the 2026-07-22 ClusterClient→gRPC migration, and `ClusterClientSiteAuditClient` is now `SiteCommunicationAuditClient` (commit `63c16d69`).
|
||||||
|
>
|
||||||
> **For Claude:** REQUIRED SUB-SKILL FLOW per milestone: `brainstorming` → `writing-plans` → `subagent-driven-development`. Use `docs/requirements/Component-AuditLog.md` + `alog.md` as the spec; this document is the roadmap that sequences milestones and locks acceptance criteria for each. **M1 carries full TDD-level task detail; M2–M8 are milestone-shape detail and will be expanded into bite-sized plans by their own writing-plans pass when their turn comes.**
|
> **For Claude:** REQUIRED SUB-SKILL FLOW per milestone: `brainstorming` → `writing-plans` → `subagent-driven-development`. Use `docs/requirements/Component-AuditLog.md` + `alog.md` as the spec; this document is the roadmap that sequences milestones and locks acceptance criteria for each. **M1 carries full TDD-level task detail; M2–M8 are milestone-shape detail and will be expanded into bite-sized plans by their own writing-plans pass when their turn comes.**
|
||||||
|
|
||||||
**Goal:** Implement central component #23 Audit Log — append-only forensic + operational record across every script-trust-boundary action — into the existing ScadaBridge codebase.
|
**Goal:** Implement central component #23 Audit Log — append-only forensic + operational record across every script-trust-boundary action — into the existing ScadaBridge codebase.
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-05-20-auditlog-m1-foundation.md",
|
"planPath": "docs/plans/2026-05-20-auditlog-m1-foundation.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": "A1", "subject": "Bundle A T1: Add audit enums (Channel, Kind, Status, ForwardState)", "status": "pending"},
|
{"id": "A1", "subject": "Bundle A T1: Add audit enums (Channel, Kind, Status, ForwardState)", "status": "completed"},
|
||||||
{"id": "A2", "subject": "Bundle A T2: Add AuditEvent record", "status": "pending", "blockedBy": ["A1"]},
|
{"id": "A2", "subject": "Bundle A T2: Add AuditEvent record", "status": "completed", "blockedBy": ["A1"]},
|
||||||
{"id": "A3", "subject": "Bundle A T3: Add IAuditWriter + ICentralAuditWriter", "status": "pending", "blockedBy": ["A2"]},
|
{"id": "A3", "subject": "Bundle A T3: Add IAuditWriter + ICentralAuditWriter", "status": "completed", "blockedBy": ["A2"]},
|
||||||
{"id": "A4", "subject": "Bundle A T4: Add audit telemetry + pull message DTOs", "status": "pending", "blockedBy": ["A2"]},
|
{"id": "A4", "subject": "Bundle A T4: Add audit telemetry + pull message DTOs", "status": "completed", "blockedBy": ["A2"]},
|
||||||
{"id": "A-rev", "subject": "Bundle A combined spec+quality review", "status": "pending", "blockedBy": ["A1", "A2", "A3", "A4"]},
|
{"id": "A-rev", "subject": "Bundle A combined spec+quality review", "status": "completed", "blockedBy": ["A1", "A2", "A3", "A4"]},
|
||||||
{"id": "B5", "subject": "Bundle B T5: ScadaBridgeDbContext.AuditLogs + IEntityTypeConfiguration<AuditEvent> with five named indexes", "status": "pending", "blockedBy": ["A-rev"]},
|
{"id": "B5", "subject": "Bundle B T5: ScadaBridgeDbContext.AuditLogs + IEntityTypeConfiguration<AuditEvent> with five named indexes", "status": "completed", "blockedBy": ["A-rev"]},
|
||||||
{"id": "B-rev", "subject": "Bundle B review", "status": "pending", "blockedBy": ["B5"]},
|
{"id": "B-rev", "subject": "Bundle B review", "status": "completed", "blockedBy": ["B5"]},
|
||||||
{"id": "C67", "subject": "Bundle C T6+T7: AddAuditLogTable migration (partition fn/scheme/table/indexes) + DB roles, with infra/mssql integration tests", "status": "pending", "blockedBy": ["B-rev"]},
|
{"id": "C67", "subject": "Bundle C T6+T7: AddAuditLogTable migration (partition fn/scheme/table/indexes) + DB roles, with infra/mssql integration tests", "status": "completed", "blockedBy": ["B-rev"]},
|
||||||
{"id": "C-rev", "subject": "Bundle C review", "status": "pending", "blockedBy": ["C67"]},
|
{"id": "C-rev", "subject": "Bundle C review", "status": "completed", "blockedBy": ["C67"]},
|
||||||
{"id": "D8", "subject": "Bundle D T8: IAuditLogRepository + EF implementation + DI registration", "status": "pending", "blockedBy": ["C-rev"]},
|
{"id": "D8", "subject": "Bundle D T8: IAuditLogRepository + EF implementation + DI registration", "status": "completed", "blockedBy": ["C-rev"]},
|
||||||
{"id": "D-rev", "subject": "Bundle D review", "status": "pending", "blockedBy": ["D8"]},
|
{"id": "D-rev", "subject": "Bundle D review", "status": "completed", "blockedBy": ["D8"]},
|
||||||
{"id": "E10", "subject": "Bundle E T10: Scaffold src/ZB.MOM.WW.ScadaBridge.AuditLog/ project + slnx entries", "status": "pending", "blockedBy": ["D-rev"]},
|
{"id": "E10", "subject": "Bundle E T10: Scaffold src/ZB.MOM.WW.ScadaBridge.AuditLog/ project + slnx entries", "status": "completed", "blockedBy": ["D-rev"]},
|
||||||
{"id": "E9", "subject": "Bundle E T9: AuditLogOptions + validator", "status": "pending", "blockedBy": ["E10"]},
|
{"id": "E9", "subject": "Bundle E T9: AuditLogOptions + validator", "status": "completed", "blockedBy": ["E10"]},
|
||||||
{"id": "E-rev", "subject": "Bundle E review", "status": "pending", "blockedBy": ["E10", "E9"]},
|
{"id": "E-rev", "subject": "Bundle E review", "status": "completed", "blockedBy": ["E10", "E9"]},
|
||||||
{"id": "F11", "subject": "Bundle F T11 (controller-direct): Register ZB.MOM.WW.ScadaBridge.AuditLog in Component-Host.md + README confirm", "status": "pending", "blockedBy": ["E-rev"]},
|
{"id": "F11", "subject": "Bundle F T11 (controller-direct): Register ZB.MOM.WW.ScadaBridge.AuditLog in Component-Host.md + README confirm", "status": "completed", "blockedBy": ["E-rev"]},
|
||||||
{"id": "FINAL-rev", "subject": "Final cross-bundle review over the whole M1 branch", "status": "pending", "blockedBy": ["F11"]},
|
{"id": "FINAL-rev", "subject": "Final cross-bundle review over the whole M1 branch", "status": "completed", "blockedBy": ["F11"]},
|
||||||
{"id": "MERGE", "subject": "Verify gate: full solution dotnet test green, then merge --no-ff to main", "status": "pending", "blockedBy": ["FINAL-rev"]},
|
{"id": "MERGE", "subject": "Verify gate: full solution dotnet test green, then merge --no-ff to main", "status": "completed", "blockedBy": ["FINAL-rev"]},
|
||||||
{"id": "ROADMAP", "subject": "Update downstream M2-M8 sections of roadmap with realities learned in M1", "status": "pending", "blockedBy": ["MERGE"]}
|
{"id": "ROADMAP", "subject": "Update downstream M2-M8 sections of roadmap with realities learned in M1", "status": "completed", "blockedBy": ["MERGE"]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-05-20T00:00:00Z"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"note": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,25 @@
|
|||||||
"spec": "alog.md (commit fec0bb1)",
|
"spec": "alog.md (commit fec0bb1)",
|
||||||
"repoNature": "design-documentation-only",
|
"repoNature": "design-documentation-only",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 0, "subject": "Task 0: Prepare branch", "status": "pending", "blockedBy": []},
|
{"id": 0, "subject": "Task 0: Prepare branch", "status": "completed", "blockedBy": []},
|
||||||
{"id": 1, "subject": "Task 1: Author Component-AuditLog.md", "status": "pending", "blockedBy": [0]},
|
{"id": 1, "subject": "Task 1: Author Component-AuditLog.md", "status": "completed", "blockedBy": [0]},
|
||||||
{"id": 2, "subject": "Task 2: Update Component-Commons.md", "status": "pending", "blockedBy": [0]},
|
{"id": 2, "subject": "Task 2: Update Component-Commons.md", "status": "completed", "blockedBy": [0]},
|
||||||
{"id": 3, "subject": "Task 3: Update Component-ConfigurationDatabase.md", "status": "pending", "blockedBy": [1]},
|
{"id": 3, "subject": "Task 3: Update Component-ConfigurationDatabase.md", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 4, "subject": "Task 4: Update Component-ClusterInfrastructure.md", "status": "pending", "blockedBy": [1]},
|
{"id": 4, "subject": "Task 4: Update Component-ClusterInfrastructure.md", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 5, "subject": "Task 5: Update Component-SiteRuntime.md", "status": "pending", "blockedBy": [1]},
|
{"id": 5, "subject": "Task 5: Update Component-SiteRuntime.md", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 6, "subject": "Task 6: Update Component-ExternalSystemGateway.md", "status": "pending", "blockedBy": [1]},
|
{"id": 6, "subject": "Task 6: Update Component-ExternalSystemGateway.md", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 7, "subject": "Task 7: Update Component-SiteCallAudit.md", "status": "pending", "blockedBy": [1]},
|
{"id": 7, "subject": "Task 7: Update Component-SiteCallAudit.md", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 8, "subject": "Task 8: Update Component-NotificationOutbox.md", "status": "pending", "blockedBy": [1]},
|
{"id": 8, "subject": "Task 8: Update Component-NotificationOutbox.md", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 9, "subject": "Task 9: Update Component-InboundAPI.md", "status": "pending", "blockedBy": [1]},
|
{"id": 9, "subject": "Task 9: Update Component-InboundAPI.md", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 10, "subject": "Task 10: Update Component-CentralUI.md", "status": "pending", "blockedBy": [1]},
|
{"id": 10, "subject": "Task 10: Update Component-CentralUI.md", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 11, "subject": "Task 11: Update Component-HealthMonitoring.md", "status": "pending", "blockedBy": [1]},
|
{"id": 11, "subject": "Task 11: Update Component-HealthMonitoring.md", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 12, "subject": "Task 12: Update Component-CLI.md", "status": "pending", "blockedBy": [1]},
|
{"id": 12, "subject": "Task 12: Update Component-CLI.md", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 13, "subject": "Task 13: Update README.md", "status": "pending", "blockedBy": [1]},
|
{"id": 13, "subject": "Task 13: Update README.md", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 14, "subject": "Task 14: Update HighLevelReqs.md", "status": "pending", "blockedBy": [1]},
|
{"id": 14, "subject": "Task 14: Update HighLevelReqs.md", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 15, "subject": "Task 15: Update CLAUDE.md", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]},
|
{"id": 15, "subject": "Task 15: Update CLAUDE.md", "status": "completed", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]},
|
||||||
{"id": 16, "subject": "Task 16: Final cross-reference verification", "status": "pending", "blockedBy": [15]},
|
{"id": 16, "subject": "Task 16: Final cross-reference verification", "status": "completed", "blockedBy": [15]},
|
||||||
{"id": 17, "subject": "Task 17: Merge to main (user-gated)", "status": "pending", "blockedBy": [16]}
|
{"id": 17, "subject": "Task 17: Merge to main (user-gated)", "status": "completed", "blockedBy": [16]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-05-20T00:00:00Z"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"note": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-05-21-audit-executionid.md",
|
"planPath": "docs/plans/2026-05-21-audit-executionid.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 50, "subject": "Task 0: Prep — verify branch + baseline", "status": "pending"},
|
{"id": 50, "subject": "Task 0: Prep — verify branch + baseline", "status": "completed"},
|
||||||
{"id": 51, "subject": "Task 1: Foundation — AuditEvent.ExecutionId + central AuditLog column + repo query", "status": "pending", "blockedBy": [50]},
|
{"id": 51, "subject": "Task 1: Foundation — AuditEvent.ExecutionId + central AuditLog column + repo query", "status": "completed", "blockedBy": [50]},
|
||||||
{"id": 52, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "pending", "blockedBy": [51]},
|
{"id": 52, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "completed", "blockedBy": [51]},
|
||||||
{"id": 53, "subject": "Task 3: Site script-side emitters stamp ExecutionId", "status": "pending", "blockedBy": [52]},
|
{"id": 53, "subject": "Task 3: Site script-side emitters stamp ExecutionId", "status": "completed", "blockedBy": [52]},
|
||||||
{"id": 54, "subject": "Task 4: Cached S&F retry-loop rows carry ExecutionId", "status": "pending", "blockedBy": [53]},
|
{"id": 54, "subject": "Task 4: Cached S&F retry-loop rows carry ExecutionId", "status": "completed", "blockedBy": [53]},
|
||||||
{"id": 55, "subject": "Task 5: Central NotifyDeliver rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
|
{"id": 55, "subject": "Task 5: Central NotifyDeliver rows carry ExecutionId", "status": "completed", "blockedBy": [52]},
|
||||||
{"id": 56, "subject": "Task 6: Inbound audit rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
|
{"id": 56, "subject": "Task 6: Inbound audit rows carry ExecutionId", "status": "completed", "blockedBy": [52]},
|
||||||
{"id": 57, "subject": "Task 7: Central UI — ExecutionId column, filter, drill-in", "status": "pending", "blockedBy": [51]},
|
{"id": 57, "subject": "Task 7: Central UI — ExecutionId column, filter, drill-in", "status": "completed", "blockedBy": [51]},
|
||||||
{"id": 58, "subject": "Task 8: CLI + ManagementService — ExecutionId filter", "status": "pending", "blockedBy": [51]},
|
{"id": 58, "subject": "Task 8: CLI + ManagementService — ExecutionId filter", "status": "completed", "blockedBy": [51]},
|
||||||
{"id": 59, "subject": "Task 9: End-to-end integration test + docs", "status": "pending", "blockedBy": [53, 54, 55, 56, 57, 58]}
|
{"id": 59, "subject": "Task 9: End-to-end integration test + docs", "status": "completed", "blockedBy": [53, 54, 55, 56, 57, 58]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-05-21T00:00:00Z"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"note": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-05-21-audit-parent-executionid.md",
|
"planPath": "docs/plans/2026-05-21-audit-parent-executionid.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 0, "subject": "Task 0: Prep — verify branch + baseline", "status": "pending"},
|
{"id": 0, "subject": "Task 0: Prep — verify branch + baseline", "status": "completed"},
|
||||||
{"id": 1, "subject": "Task 1: Foundation — AuditEvent.ParentExecutionId + central AuditLog column", "status": "pending", "blockedBy": [0]},
|
{"id": 1, "subject": "Task 1: Foundation — AuditEvent.ParentExecutionId + central AuditLog column", "status": "completed", "blockedBy": [0]},
|
||||||
{"id": 2, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "pending", "blockedBy": [1]},
|
{"id": 2, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 3, "subject": "Task 3: Inbound request id minting + RouteToCallRequest.ParentExecutionId", "status": "pending", "blockedBy": [0]},
|
{"id": 3, "subject": "Task 3: Inbound request id minting + RouteToCallRequest.ParentExecutionId", "status": "completed", "blockedBy": [0]},
|
||||||
{"id": 4, "subject": "Task 4: Thread ParentExecutionId into routed script ScriptRuntimeContext", "status": "pending", "blockedBy": [3]},
|
{"id": 4, "subject": "Task 4: Thread ParentExecutionId into routed script ScriptRuntimeContext", "status": "completed", "blockedBy": [3]},
|
||||||
{"id": 5, "subject": "Task 5: Site script-side emitters stamp ParentExecutionId", "status": "pending", "blockedBy": [4, 2]},
|
{"id": 5, "subject": "Task 5: Site script-side emitters stamp ParentExecutionId", "status": "completed", "blockedBy": [4, 2]},
|
||||||
{"id": 6, "subject": "Task 6: Cached S&F retry-loop rows carry ParentExecutionId", "status": "pending", "blockedBy": [5]},
|
{"id": 6, "subject": "Task 6: Cached S&F retry-loop rows carry ParentExecutionId", "status": "completed", "blockedBy": [5]},
|
||||||
{"id": 7, "subject": "Task 7: Central NotifyDeliver rows carry ParentExecutionId", "status": "pending", "blockedBy": [5, 1]},
|
{"id": 7, "subject": "Task 7: Central NotifyDeliver rows carry ParentExecutionId", "status": "completed", "blockedBy": [5, 1]},
|
||||||
{"id": 8, "subject": "Task 8: Repository — GetExecutionTreeAsync", "status": "pending", "blockedBy": [1]},
|
{"id": 8, "subject": "Task 8: Repository — GetExecutionTreeAsync", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 9, "subject": "Task 9: Central UI — ParentExecutionId column, filter, parent drill-in", "status": "pending", "blockedBy": [1]},
|
{"id": 9, "subject": "Task 9: Central UI — ParentExecutionId column, filter, parent drill-in", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 10, "subject": "Task 10: Central UI — execution-chain tree view", "status": "pending", "blockedBy": [8, 9]},
|
{"id": 10, "subject": "Task 10: Central UI — execution-chain tree view", "status": "completed", "blockedBy": [8, 9]},
|
||||||
{"id": 11, "subject": "Task 11: CLI + ManagementService — ParentExecutionId filter", "status": "pending", "blockedBy": [1]},
|
{"id": 11, "subject": "Task 11: CLI + ManagementService — ParentExecutionId filter", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 12, "subject": "Task 12: End-to-end integration test + docs", "status": "pending", "blockedBy": [5, 6, 7, 10, 11]}
|
{"id": 12, "subject": "Task 12: End-to-end integration test + docs", "status": "completed", "blockedBy": [5, 6, 7, 10, 11]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-05-21"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"note": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -929,12 +929,12 @@ git commit -m "chore(audit): smoke-verify SourceNode end-to-end across cluster"
|
|||||||
|
|
||||||
## Acceptance Criteria (the whole-plan checklist)
|
## Acceptance Criteria (the whole-plan checklist)
|
||||||
|
|
||||||
- [ ] Every audit row written from this commit forward carries `SourceNode` populated (`central-a/b` for central direct-write, `node-a/b` for site rows).
|
- [x] Every audit row written from this commit forward carries `SourceNode` populated (`central-a/b` for central direct-write, `node-a/b` for site rows).
|
||||||
- [ ] Every new `Notifications` and `SiteCalls` row carries `SourceNode` from the site.
|
- [x] Every new `Notifications` and `SiteCalls` row carries `SourceNode` from the site.
|
||||||
- [ ] `IX_AuditLog_Node_Occurred` exists in central MS SQL.
|
- [x] `IX_AuditLog_Node_Occurred` exists in central MS SQL.
|
||||||
- [ ] Site SQLite `AuditLog` and `OperationTracking` tables both have `SourceNode TEXT NULL`; existing site DBs are upgraded idempotently on startup.
|
- [x] Site SQLite `AuditLog` and `OperationTracking` tables both have `SourceNode TEXT NULL`; existing site DBs are upgraded idempotently on startup.
|
||||||
- [ ] Proto `AuditEventDto.source_node = 22` and `SiteCallOperationalDto.source_node = 12` exist; no field numbers reused.
|
- [x] Proto `AuditEventDto.source_node = 22` and `SiteCallOperationalDto.source_node = 12` exist; no field numbers reused.
|
||||||
- [ ] Central UI Audit Log, Notifications, and Site Calls pages all display a "Node" column and support filtering by it.
|
- [x] Central UI Audit Log, Notifications, and Site Calls pages all display a "Node" column and support filtering by it.
|
||||||
- [ ] All test projects green.
|
- [x] All test projects green.
|
||||||
- [ ] Cluster comes up clean via `bash docker/deploy.sh`; CLI smoke confirms expected node names land in the central tables.
|
- [x] Cluster comes up clean via `bash docker/deploy.sh`; CLI smoke confirms expected node names land in the central tables. (`NodeName` is now startup-validated — an empty value fails fast rather than NULLing `SourceNode`, commit `4a0462e4` — and the rig has redeployed many times since.)
|
||||||
- [ ] Design docs (already committed in Task 0) match the implementation.
|
- [x] Design docs (already committed in Task 0) match the implementation.
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-05-23-audit-source-node.md",
|
"planPath": "docs/plans/2026-05-23-audit-source-node.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 1, "subject": "Task 0: Branch + Snapshot", "status": "pending"},
|
{"id": 1, "subject": "Task 0: Branch + Snapshot", "status": "completed"},
|
||||||
{"id": 2, "subject": "Task 1: NodeOptions.NodeName + INodeIdentityProvider", "status": "pending", "blockedBy": [1]},
|
{"id": 2, "subject": "Task 1: NodeOptions.NodeName + INodeIdentityProvider", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 3, "subject": "Task 2: Add SourceNode to AuditEvent record", "status": "pending", "blockedBy": [2]},
|
{"id": 3, "subject": "Task 2: Add SourceNode to AuditEvent record", "status": "completed", "blockedBy": [2]},
|
||||||
{"id": 4, "subject": "Task 3: Add SourceNode to SiteCallOperational + SiteCall entity", "status": "pending", "blockedBy": [2]},
|
{"id": 4, "subject": "Task 3: Add SourceNode to SiteCallOperational + SiteCall entity", "status": "completed", "blockedBy": [2]},
|
||||||
{"id": 5, "subject": "Task 4: Add SourceNode to Notification entity + NotificationSubmit", "status": "pending", "blockedBy": [2]},
|
{"id": 5, "subject": "Task 4: Add SourceNode to Notification entity + NotificationSubmit", "status": "completed", "blockedBy": [2]},
|
||||||
{"id": 6, "subject": "Task 5: Add source_node to proto + update DTO mappers", "status": "pending", "blockedBy": [3, 4]},
|
{"id": 6, "subject": "Task 5: Add source_node to proto + update DTO mappers", "status": "completed", "blockedBy": [3, 4]},
|
||||||
{"id": 7, "subject": "Task 6: EF migration — SourceNode on AuditLog + IX_AuditLog_Node_Occurred", "status": "pending", "blockedBy": [3]},
|
{"id": 7, "subject": "Task 6: EF migration — SourceNode on AuditLog + IX_AuditLog_Node_Occurred", "status": "completed", "blockedBy": [3]},
|
||||||
{"id": 8, "subject": "Task 7: EF migration — SourceNode on Notifications", "status": "pending", "blockedBy": [5]},
|
{"id": 8, "subject": "Task 7: EF migration — SourceNode on Notifications", "status": "completed", "blockedBy": [5]},
|
||||||
{"id": 9, "subject": "Task 8: EF migration — SourceNode on SiteCalls", "status": "pending", "blockedBy": [4]},
|
{"id": 9, "subject": "Task 8: EF migration — SourceNode on SiteCalls", "status": "completed", "blockedBy": [4]},
|
||||||
{"id": 10, "subject": "Task 9: Site SQLite AuditLog — add SourceNode (idempotent upgrade)", "status": "pending", "blockedBy": [3]},
|
{"id": 10, "subject": "Task 9: Site SQLite AuditLog — add SourceNode (idempotent upgrade)", "status": "completed", "blockedBy": [3]},
|
||||||
{"id": 11, "subject": "Task 10: Site SQLite OperationTracking — add SourceNode", "status": "pending", "blockedBy": [4]},
|
{"id": 11, "subject": "Task 10: Site SQLite OperationTracking — add SourceNode", "status": "completed", "blockedBy": [4]},
|
||||||
{"id": 12, "subject": "Task 11: Stamp SourceNode at site SqliteAuditWriter", "status": "pending", "blockedBy": [2, 10]},
|
{"id": 12, "subject": "Task 11: Stamp SourceNode at site SqliteAuditWriter", "status": "completed", "blockedBy": [2, 10]},
|
||||||
{"id": 13, "subject": "Task 12: Stamp SourceNode at CentralAuditWriter + persist via repo", "status": "pending", "blockedBy": [2, 7]},
|
{"id": 13, "subject": "Task 12: Stamp SourceNode at CentralAuditWriter + persist via repo", "status": "completed", "blockedBy": [2, 7]},
|
||||||
{"id": 14, "subject": "Task 13: Carry SourceNode through Notifications S&F handoff", "status": "pending", "blockedBy": [2, 5, 8]},
|
{"id": 14, "subject": "Task 13: Carry SourceNode through Notifications S&F handoff", "status": "completed", "blockedBy": [2, 5, 8]},
|
||||||
{"id": 15, "subject": "Task 14: Carry SourceNode through cached-call telemetry → SiteCalls", "status": "pending", "blockedBy": [2, 9, 11]},
|
{"id": 15, "subject": "Task 14: Carry SourceNode through cached-call telemetry → SiteCalls", "status": "completed", "blockedBy": [2, 9, 11]},
|
||||||
{"id": 16, "subject": "Task 15: UI — Node column + filter on AuditLog grid", "status": "pending", "blockedBy": [7]},
|
{"id": 16, "subject": "Task 15: UI — Node column + filter on AuditLog grid", "status": "completed", "blockedBy": [7]},
|
||||||
{"id": 17, "subject": "Task 16: UI — Node column + filter on Notifications grid", "status": "pending", "blockedBy": [7]},
|
{"id": 17, "subject": "Task 16: UI — Node column + filter on Notifications grid", "status": "completed", "blockedBy": [7]},
|
||||||
{"id": 18, "subject": "Task 17: UI — Node column + filter on SiteCalls grid", "status": "pending", "blockedBy": [7]},
|
{"id": 18, "subject": "Task 17: UI — Node column + filter on SiteCalls grid", "status": "completed", "blockedBy": [7]},
|
||||||
{"id": 19, "subject": "Task 18: Docker appsettings — NodeName on all 8 nodes", "status": "pending", "blockedBy": [2]},
|
{"id": 19, "subject": "Task 18: Docker appsettings — NodeName on all 8 nodes", "status": "completed", "blockedBy": [2]},
|
||||||
{"id": 20, "subject": "Task 19: Full build + targeted test sweep", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 19]},
|
{"id": 20, "subject": "Task 19: Full build + targeted test sweep", "status": "completed", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 19]},
|
||||||
{"id": 21, "subject": "Task 20: Docker redeploy + smoke verify", "status": "pending", "blockedBy": [20]}
|
{"id": 21, "subject": "Task 20: Docker redeploy + smoke verify", "status": "completed", "blockedBy": [20]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-05-23T00:00:00Z"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"note": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,17 +132,17 @@ same "options validation" path used for other AuditLog settings.
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] `AuditLog:InboundMaxBytes` option exists on the AuditLog options class,
|
- [x] `AuditLog:InboundMaxBytes` option exists on the AuditLog options class,
|
||||||
with the documented default and bounds, validated at startup.
|
with the documented default and bounds, validated at startup.
|
||||||
- [ ] Inbound request middleware writes `RequestSummary` and `ResponseSummary`
|
- [x] Inbound request middleware writes `RequestSummary` and `ResponseSummary`
|
||||||
using the inbound ceiling instead of the 8 KB / 64 KB defaults.
|
using the inbound ceiling instead of the 8 KB / 64 KB defaults.
|
||||||
- [ ] Other channels' rows (e.g. an `ApiOutbound.ApiCall` over the limit) still
|
- [x] Other channels' rows (e.g. an `ApiOutbound.ApiCall` over the limit) still
|
||||||
truncate at 8 KB (64 KB on error rows) — regression-tested.
|
truncate at 8 KB (64 KB on error rows) — regression-tested.
|
||||||
- [ ] `PayloadTruncated = 1` on an inbound row iff request body or response
|
- [x] `PayloadTruncated = 1` on an inbound row iff request body or response
|
||||||
body exceeded `InboundMaxBytes`.
|
body exceeded `InboundMaxBytes`.
|
||||||
- [ ] Header redaction list and per-target body redactors still apply to
|
- [x] Header redaction list and per-target body redactors still apply to
|
||||||
inbound rows.
|
inbound rows.
|
||||||
- [ ] Redactor failure on an inbound row still produces `<redacted: redactor
|
- [x] Redactor failure on an inbound row still produces `<redacted: redactor
|
||||||
error>` and increments `AuditRedactionFailure`.
|
error>` and increments `AuditRedactionFailure`.
|
||||||
- [ ] `Component-AuditLog.md` and `Component-InboundAPI.md` updated as
|
- [x] `Component-AuditLog.md` and `Component-InboundAPI.md` updated as
|
||||||
described in **Doc Edits**.
|
described in **Doc Edits**.
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-05-23-inbound-api-full-response-audit.md",
|
"planPath": "docs/plans/2026-05-23-inbound-api-full-response-audit.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 1, "subject": "Task 0: Prep — branch, baseline build", "status": "pending"},
|
{"id": 1, "subject": "Task 0: Prep — branch, baseline build", "status": "completed"},
|
||||||
{"id": 2, "subject": "Task 1: Add InboundMaxBytes to AuditLogOptions (TDD)", "status": "pending", "blockedBy": [1]},
|
{"id": 2, "subject": "Task 1: Add InboundMaxBytes to AuditLogOptions (TDD)", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 3, "subject": "Task 2: Wire InboundMaxBytes into DefaultAuditPayloadFilter (TDD)", "status": "pending", "blockedBy": [2]},
|
{"id": 3, "subject": "Task 2: Wire InboundMaxBytes into DefaultAuditPayloadFilter (TDD)", "status": "completed", "blockedBy": [2]},
|
||||||
{"id": 4, "subject": "Task 3: Capture response body in AuditWriteMiddleware (TDD)", "status": "pending", "blockedBy": [3]},
|
{"id": 4, "subject": "Task 3: Capture response body in AuditWriteMiddleware (TDD)", "status": "completed", "blockedBy": [3]},
|
||||||
{"id": 5, "subject": "Task 4: Update Component-AuditLog.md", "status": "pending", "blockedBy": [4]},
|
{"id": 5, "subject": "Task 4: Update Component-AuditLog.md", "status": "completed", "blockedBy": [4]},
|
||||||
{"id": 6, "subject": "Task 5: Update Component-InboundAPI.md", "status": "pending", "blockedBy": [5]},
|
{"id": 6, "subject": "Task 5: Update Component-InboundAPI.md", "status": "completed", "blockedBy": [5]},
|
||||||
{"id": 7, "subject": "Task 6: Final build + full test run + branch summary", "status": "pending", "blockedBy": [6]}
|
{"id": 7, "subject": "Task 6: Final build + full test run + branch summary", "status": "completed", "blockedBy": [6]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-05-23"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"note": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# Env2 + Transport Manual Verification Checklist
|
# Env2 + Transport Manual Verification Checklist
|
||||||
|
|
||||||
|
> **RETIRED 2026-08-01 — never run as written; superseded by automated suites + a live cross-environment run.**
|
||||||
|
> The step list below predates the M8 import-wizard Map step and was never annotated. Instead of
|
||||||
|
> refreshing ~48 stale steps, the core scenario this checklist exists for was run live on 2026-08-01
|
||||||
|
> (rig session, task #12): CLI `bundle export` from the primary cluster (encrypted, `--include-dependencies`,
|
||||||
|
> template closure + shared scripts + external system + site-a + instances) → `bundle preview` against the
|
||||||
|
> empty env2 (correct Map requirements surfaced) → `bundle import --create-missing-sites
|
||||||
|
> --create-missing-connections` into env2 → 20 entities added, secrets (`AuthConfiguration`) intact,
|
||||||
|
> instances landed `NotDeployed`, connection FK wired to the created site. **The run caught a real bug**
|
||||||
|
> (create-missing site id never materialised before the connection insert → FK 547 on real SQL Server,
|
||||||
|
> masked by the in-memory test provider) — fixed the same day with an SQLite regression test
|
||||||
|
> (`CreateMissingSiteRelationalTests`, commit `0c9dffed`). Env2 boot/PSK/LocalDb were separately proven
|
||||||
|
> PASS 3/3 by Gitea #31 (2026-07-23). Remaining coverage lives in
|
||||||
|
> `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests` (RoundTrip/RoundTripEquivalence/
|
||||||
|
> ConflictResolution/ValidationFailure/SiteInstanceImport/CreateMissingSiteRelational). This checklist
|
||||||
|
> is retired — do not run or refresh it.
|
||||||
|
|
||||||
**Date created:** 2026-05-24
|
**Date created:** 2026-05-24
|
||||||
**Companion to:** [`2026-05-24-second-environment-design.md`](2026-05-24-second-environment-design.md), [`Component-Transport.md`](../requirements/Component-Transport.md)
|
**Companion to:** [`2026-05-24-second-environment-design.md`](2026-05-24-second-environment-design.md), [`Component-Transport.md`](../requirements/Component-Transport.md)
|
||||||
**Goal:** Exercise the Transport (#24) bundle export/import flow against two real running environments (primary + env2).
|
**Goal:** Exercise the Transport (#24) bundle export/import flow against two real running environments (primary + env2).
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Transport Manual Verification
|
# Transport Manual Verification
|
||||||
|
|
||||||
|
> **RETIRED 2026-08-01 — never run as written; ~80% overlap with
|
||||||
|
> `2026-05-24-second-environment-verification.md`, which carries the full retirement rationale.**
|
||||||
|
> The steps predate the M8 import-wizard Map step (numbering stale). The export→preview→import flow
|
||||||
|
> was proven live cross-environment on 2026-08-01 via the CLI (see the retirement banner in the env2
|
||||||
|
> checklist), and the UI wizard surface is covered by the Transport Playwright/bUnit suites. Retired —
|
||||||
|
> do not run or refresh.
|
||||||
|
|
||||||
This document is a one-time manual verification to be run against the docker cluster after the Transport feature is fully built and `bash docker/deploy.sh` has rebuilt the image. Each step is sequential and assumes the previous step succeeded. The entire flow takes approximately 15 minutes.
|
This document is a one-time manual verification to be run against the docker cluster after the Transport feature is fully built and `bash docker/deploy.sh` has rebuilt the image. Each step is sequential and assumes the previous step succeeded. The entire flow takes approximately 15 minutes.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|||||||
@@ -1758,6 +1758,16 @@ git commit -m "feat(centralui): add OPC UA browse button + override column to In
|
|||||||
|
|
||||||
### Task 19: End-to-end smoke (manual)
|
### Task 19: End-to-end smoke (manual)
|
||||||
|
|
||||||
|
> **PASS 2026-08-01 (online + offline)** — run live on the docker rig against a purpose-built
|
||||||
|
> deployed instance (data-sourced Float attribute bound to site-a's "OPC PLC Simulator";
|
||||||
|
> artifacts deleted after). Online: Browse dialog opened, root populated (Server visible),
|
||||||
|
> expanded OpcPlc → Telemetry → Basic, selected `StepUp` — footer + override stored the durable
|
||||||
|
> `nsu=http://microsoft.com/Opc/OpcPlc/;s=StepUp` form and it persisted across save + reload.
|
||||||
|
> Offline: with BOTH site-a nodes stopped, Browse showed the error banner
|
||||||
|
> (`StatusCode="Unavailable"` + Retry) with the manual-paste field still usable; a hand-typed
|
||||||
|
> `ns=3;s=TestChildObject.TestInt` (bare-index advisory shown as designed) went Use → Select →
|
||||||
|
> Save and persisted across reload while the site was down. Pair restarted staggered and rejoined.
|
||||||
|
|
||||||
**Classification:** trivial
|
**Classification:** trivial
|
||||||
**Estimated implement time:** ~5 min (manual)
|
**Estimated implement time:** ~5 min (manual)
|
||||||
**Parallelizable with:** none (validates Tasks 1–18 together)
|
**Parallelizable with:** none (validates Tasks 1–18 together)
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-05-28-opcua-tag-browser.md",
|
"planPath": "docs/plans/2026-05-28-opcua-tag-browser.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 70, "subject": "Task 1: Add DataSourceReferenceOverride to InstanceConnectionBinding entity", "status": "pending"},
|
{"id": 70, "subject": "Task 1: Add DataSourceReferenceOverride to InstanceConnectionBinding entity", "status": "completed"},
|
||||||
{"id": 71, "subject": "Task 2: Add override to ConnectionBinding wire record + ManagementActor mapping", "status": "pending"},
|
{"id": 71, "subject": "Task 2: Add override to ConnectionBinding wire record + ManagementActor mapping", "status": "completed"},
|
||||||
{"id": 72, "subject": "Task 3: EF mapping for DataSourceReferenceOverride column", "status": "pending"},
|
{"id": 72, "subject": "Task 3: EF mapping for DataSourceReferenceOverride column", "status": "completed"},
|
||||||
{"id": 73, "subject": "Task 4: EF Core migration AddInstanceConnectionBindingOverride", "status": "pending", "blockedBy": [72]},
|
{"id": 73, "subject": "Task 4: EF Core migration AddInstanceConnectionBindingOverride", "status": "completed", "blockedBy": [72]},
|
||||||
{"id": 74, "subject": "Task 5: IBrowsableDataConnection interface + BrowseNode types", "status": "pending"},
|
{"id": 74, "subject": "Task 5: IBrowsableDataConnection interface + BrowseNode types", "status": "completed"},
|
||||||
{"id": 75, "subject": "Task 6: BrowseCommands.cs (BrowseOpcUaNodeCommand + result + failure)", "status": "pending"},
|
{"id": 75, "subject": "Task 6: BrowseCommands.cs (BrowseOpcUaNodeCommand + result + failure)", "status": "completed"},
|
||||||
{"id": 76, "subject": "Task 7: Add BrowseChildrenAsync to IOpcUaClient", "status": "pending", "blockedBy": [74, 75]},
|
{"id": 76, "subject": "Task 7: Add BrowseChildrenAsync to IOpcUaClient", "status": "completed", "blockedBy": [74, 75]},
|
||||||
{"id": 77, "subject": "Task 8: Implement BrowseChildrenAsync on RealOpcUaClient", "status": "pending", "blockedBy": [76]},
|
{"id": 77, "subject": "Task 8: Implement BrowseChildrenAsync on RealOpcUaClient", "status": "completed", "blockedBy": [76]},
|
||||||
{"id": 78, "subject": "Task 9: Implement IBrowsableDataConnection on OpcUaDataConnection", "status": "pending", "blockedBy": [76]},
|
{"id": 78, "subject": "Task 9: Implement IBrowsableDataConnection on OpcUaDataConnection", "status": "completed", "blockedBy": [76]},
|
||||||
{"id": 79, "subject": "Task 10: Handle BrowseOpcUaNodeCommand in DataConnectionManagerActor", "status": "pending", "blockedBy": [75, 76]},
|
{"id": 79, "subject": "Task 10: Handle BrowseOpcUaNodeCommand in DataConnectionManagerActor", "status": "completed", "blockedBy": [75, 76]},
|
||||||
{"id": 80, "subject": "Task 11: Forward BrowseOpcUaNodeCommand in SiteCommunicationActor", "status": "pending", "blockedBy": [79]},
|
{"id": 80, "subject": "Task 11: Forward BrowseOpcUaNodeCommand in SiteCommunicationActor", "status": "completed", "blockedBy": [79]},
|
||||||
{"id": 81, "subject": "Task 12: Apply override in FlatteningService.ApplyConnectionBindings", "status": "pending", "blockedBy": [70]},
|
{"id": 81, "subject": "Task 12: Apply override in FlatteningService.ApplyConnectionBindings", "status": "completed", "blockedBy": [70]},
|
||||||
{"id": 82, "subject": "Task 13: Revision-hash regression test", "status": "pending", "blockedBy": [81]},
|
{"id": 82, "subject": "Task 13: Revision-hash regression test", "status": "completed", "blockedBy": [81]},
|
||||||
{"id": 83, "subject": "Task 14: IOpcUaBrowseService + impl + DI registration", "status": "pending", "blockedBy": [75]},
|
{"id": 83, "subject": "Task 14: IOpcUaBrowseService + impl + DI registration", "status": "completed", "blockedBy": [75]},
|
||||||
{"id": 84, "subject": "Task 15: Scaffold OpcUaBrowserDialog modal", "status": "pending"},
|
{"id": 84, "subject": "Task 15: Scaffold OpcUaBrowserDialog modal", "status": "completed"},
|
||||||
{"id": 85, "subject": "Task 16: Tree rendering + lazy load + selection in dialog", "status": "pending", "blockedBy": [83, 84]},
|
{"id": 85, "subject": "Task 16: Tree rendering + lazy load + selection in dialog", "status": "completed", "blockedBy": [83, 84]},
|
||||||
{"id": 86, "subject": "Task 17: Error banner mapping + polish on dialog", "status": "pending", "blockedBy": [85]},
|
{"id": 86, "subject": "Task 17: Error banner mapping + polish on dialog", "status": "completed", "blockedBy": [85]},
|
||||||
{"id": 87, "subject": "Task 18: Add Override column + Browse button to InstanceConfigure.razor", "status": "pending", "blockedBy": [83, 86]},
|
{"id": 87, "subject": "Task 18: Add Override column + Browse button to InstanceConfigure.razor", "status": "completed", "blockedBy": [83, 86]},
|
||||||
{"id": 88, "subject": "Task 19: End-to-end manual smoke (online + offline)", "status": "pending", "blockedBy": [70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87]},
|
{"id": 88, "subject": "Task 19: End-to-end manual smoke (online + offline)", "status": "completed", "blockedBy": [70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87], "notes": "Completed 2026-08-01: manual smoke PASS, online + offline paths verified live (commit 6dc5d94c). Status flipped from stale 'pending' by the 2026-08-07 truth sweep."},
|
||||||
{"id": 89, "subject": "Task 20: Update Component-DataConnectionLayer.md", "status": "pending", "blockedBy": [81]},
|
{"id": 89, "subject": "Task 20: Update Component-DataConnectionLayer.md", "status": "completed", "blockedBy": [81]},
|
||||||
{"id": 90, "subject": "Task 21: Update Component-TemplateEngine.md", "status": "pending", "blockedBy": [81]},
|
{"id": 90, "subject": "Task 21: Update Component-TemplateEngine.md", "status": "completed", "blockedBy": [81]},
|
||||||
{"id": 91, "subject": "Task 22: Update Component-CentralUI.md", "status": "pending", "blockedBy": [87]}
|
{"id": 91, "subject": "Task 22: Update Component-CentralUI.md", "status": "completed", "blockedBy": [87]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-05-28T00:00:00Z"
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||||
|
"lastUpdated": "2026-08-01"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ScadaLink → ZB.MOM.WW.ScadaBridge Rename — Design
|
# ScadaLink → ZB.MOM.WW.ScadaBridge Rename — Design
|
||||||
|
|
||||||
**Date:** 2026-05-28
|
**Date:** 2026-05-28
|
||||||
**Status:** Approved, in implementation
|
**Status:** Implemented (commit `7b0b9c73`)
|
||||||
**Scope:** Repo-wide rename of the product from "ScadaLink" to "ScadaBridge" and addition of the `ZB.MOM.WW` company prefix to every .NET project. Code, runtime artifacts (containers, network, databases), docs, and CLI config.
|
**Scope:** Repo-wide rename of the product from "ScadaLink" to "ScadaBridge" and addition of the `ZB.MOM.WW` company prefix to every .NET project. Code, runtime artifacts (containers, network, databases), docs, and CLI config.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
@@ -115,7 +115,7 @@ Drop volume + re-seed. `docker compose -f infra/docker-compose.yml down -v` then
|
|||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
- Sister repos `~/Desktop/MxAccessGateway`, `~/Desktop/OtOpcUa` — independent codebases, independent timelines.
|
- Sister repos `~/Desktop/MxAccessGateway`, `~/Desktop/OtOpcUa` — independent codebases, independent timelines.
|
||||||
- Repo folder `~/Desktop/scadalink-design` — left as-is to preserve Claude Code memory paths and user shell context.
|
- Repo folder `~/Desktop/scadalink-design` — left as-is to preserve Claude Code memory paths and user shell context. *(2026-08-01: this exclusion was later reversed and completed — see `2026-05-31-folder-repo-rename-scadabridge-plan.md` / `-design.md`, which renamed the folder and the Gitea repo to ScadaBridge.)*
|
||||||
- Existing Transport bundle manifests with `SourceEnvironment = "docker-cluster"` — those values are deployment IDs and stay stable. If the user later wants to rename the deployment IDs themselves, that's a separate design.
|
- Existing Transport bundle manifests with `SourceEnvironment = "docker-cluster"` — those values are deployment IDs and stay stable. If the user later wants to rename the deployment IDs themselves, that's a separate design.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|||||||
@@ -1,34 +1,35 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-06-03-component-reference-docs.md",
|
"planPath": "docs/plans/2026-06-03-component-reference-docs.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 19, "subject": "Task 0: Scaffold + shared assets", "status": "pending"},
|
{"id": 19, "subject": "Task 0: Scaffold + shared assets", "status": "completed"},
|
||||||
{"id": 20, "subject": "Task 1: Pilot exemplar AuditLog.md (approval gate)", "status": "pending", "blockedBy": [19]},
|
{"id": 20, "subject": "Task 1: Pilot exemplar AuditLog.md (approval gate)", "status": "completed", "blockedBy": [19]},
|
||||||
{"id": 21, "subject": "Task 2: Commons.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 21, "subject": "Task 2: Commons.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 22, "subject": "Task 3: ConfigurationDatabase.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 22, "subject": "Task 3: ConfigurationDatabase.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 23, "subject": "Task 4: Communication.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 23, "subject": "Task 4: Communication.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 24, "subject": "Task 5: ClusterInfrastructure.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 24, "subject": "Task 5: ClusterInfrastructure.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 25, "subject": "Task 6: Host.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 25, "subject": "Task 6: Host.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 26, "subject": "Task 7: Security.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 26, "subject": "Task 7: Security.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 27, "subject": "Task 8: TemplateEngine.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 27, "subject": "Task 8: TemplateEngine.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 28, "subject": "Task 9: DeploymentManager.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 28, "subject": "Task 9: DeploymentManager.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 29, "subject": "Task 10: SiteRuntime.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 29, "subject": "Task 10: SiteRuntime.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 30, "subject": "Task 11: DataConnectionLayer.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 30, "subject": "Task 11: DataConnectionLayer.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 31, "subject": "Task 12: StoreAndForward.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 31, "subject": "Task 12: StoreAndForward.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 32, "subject": "Task 13: ExternalSystemGateway.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 32, "subject": "Task 13: ExternalSystemGateway.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 33, "subject": "Task 14: NotificationService.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 33, "subject": "Task 14: NotificationService.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 34, "subject": "Task 15: NotificationOutbox.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 34, "subject": "Task 15: NotificationOutbox.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 35, "subject": "Task 16: SiteCallAudit.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 35, "subject": "Task 16: SiteCallAudit.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 36, "subject": "Task 17: HealthMonitoring.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 36, "subject": "Task 17: HealthMonitoring.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 37, "subject": "Task 18: SiteEventLogging.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 37, "subject": "Task 18: SiteEventLogging.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 38, "subject": "Task 19: InboundAPI.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 38, "subject": "Task 19: InboundAPI.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 39, "subject": "Task 20: ManagementService.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 39, "subject": "Task 20: ManagementService.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 40, "subject": "Task 21: CLI.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 40, "subject": "Task 21: CLI.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 41, "subject": "Task 22: Transport.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 41, "subject": "Task 22: Transport.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 42, "subject": "Task 23: CentralUI.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 42, "subject": "Task 23: CentralUI.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 43, "subject": "Task 24: TraefikProxy.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 43, "subject": "Task 24: TraefikProxy.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 44, "subject": "Task 25: TreeView.md reference doc", "status": "pending", "blockedBy": [20]},
|
{"id": 44, "subject": "Task 25: TreeView.md reference doc", "status": "completed", "blockedBy": [20]},
|
||||||
{"id": 45, "subject": "Task 26: Index + README link", "status": "pending", "blockedBy": [20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44]},
|
{"id": 45, "subject": "Task 26: Index + README link", "status": "completed", "blockedBy": [20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44]},
|
||||||
{"id": 46, "subject": "Task 27: Verification & fix pass", "status": "pending", "blockedBy": [45]}
|
{"id": 46, "subject": "Task 27: Verification & fix pass", "status": "completed", "blockedBy": [45]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-06-03"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-06-05-playwright-coverage-expansion.md",
|
"planPath": "docs/plans/2026-06-05-playwright-coverage-expansion.md",
|
||||||
"lastUpdated": "2026-06-05T00:00:00Z",
|
"lastUpdated": "2026-08-01T00:00:00Z",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code",
|
||||||
"nativeTaskIdBase": 57,
|
"nativeTaskIdBase": 57,
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 0, "nativeId": 57, "subject": "Task 0: Add CLI ProjectReference to test project", "status": "pending"},
|
{"id": 0, "nativeId": 57, "subject": "Task 0: Add CLI ProjectReference to test project", "status": "completed"},
|
||||||
{"id": 1, "nativeId": 58, "subject": "Task 1: CliRunner core + ClusterAvailability probe", "status": "pending", "blockedBy": [0]},
|
{"id": 1, "nativeId": 58, "subject": "Task 1: CliRunner core + ClusterAvailability probe", "status": "completed", "blockedBy": [0]},
|
||||||
{"id": 2, "nativeId": 59, "subject": "Task 2: CliRunner typed fixture helpers", "status": "pending", "blockedBy": [1]},
|
{"id": 2, "nativeId": 59, "subject": "Task 2: CliRunner typed fixture helpers", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 3, "nativeId": 60, "subject": "Task 3: Standardize skip policy + skip-count logging", "status": "pending", "blockedBy": [1]},
|
{"id": 3, "nativeId": 60, "subject": "Task 3: Standardize skip policy + skip-count logging", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 4, "nativeId": 61, "subject": "Task 4: DeploymentFixture (ephemeral instance on site-a)", "status": "pending", "blockedBy": [2]},
|
{"id": 4, "nativeId": 61, "subject": "Task 4: DeploymentFixture (ephemeral instance on site-a)", "status": "completed", "blockedBy": [2]},
|
||||||
{"id": 5, "nativeId": 62, "subject": "Task 5: DeploymentActionTests.Deploy", "status": "pending", "blockedBy": [4]},
|
{"id": 5, "nativeId": 62, "subject": "Task 5: DeploymentActionTests.Deploy", "status": "completed", "blockedBy": [4]},
|
||||||
{"id": 6, "nativeId": 63, "subject": "Task 6: DeploymentActionTests.Enable + Disable", "status": "pending", "blockedBy": [5]},
|
{"id": 6, "nativeId": 63, "subject": "Task 6: DeploymentActionTests.Enable + Disable", "status": "completed", "blockedBy": [5]},
|
||||||
{"id": 7, "nativeId": 64, "subject": "Task 7: DeploymentActionTests.Delete", "status": "pending", "blockedBy": [6]},
|
{"id": 7, "nativeId": 64, "subject": "Task 7: DeploymentActionTests.Delete", "status": "completed", "blockedBy": [6]},
|
||||||
{"id": 8, "nativeId": 65, "subject": "Task 8: Notification retry/discard + ParkedMessages query", "status": "pending", "blockedBy": [2]},
|
{"id": 8, "nativeId": 65, "subject": "Task 8: Notification retry/discard + ParkedMessages query", "status": "completed", "blockedBy": [2]},
|
||||||
{"id": 9, "nativeId": 66, "subject": "Task 9: Transport Import round-trip", "status": "pending", "blockedBy": [2]},
|
{"id": 9, "nativeId": 66, "subject": "Task 9: Transport Import round-trip", "status": "completed", "blockedBy": [2]},
|
||||||
{"id": 10, "nativeId": 67, "subject": "Task 10: Site CRUD round-trip", "status": "pending", "blockedBy": [2]},
|
{"id": 10, "nativeId": 67, "subject": "Task 10: Site CRUD round-trip", "status": "completed", "blockedBy": [2]},
|
||||||
{"id": 11, "nativeId": 68, "subject": "Task 11: Template CRUD round-trip", "status": "pending", "blockedBy": [2]},
|
{"id": 11, "nativeId": 68, "subject": "Task 11: Template CRUD round-trip", "status": "completed", "blockedBy": [2]},
|
||||||
{"id": 12, "nativeId": 69, "subject": "Task 12: LDAP mapping CRUD round-trip", "status": "pending", "blockedBy": [1]},
|
{"id": 12, "nativeId": 69, "subject": "Task 12: LDAP mapping CRUD round-trip", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 13, "nativeId": 70, "subject": "Task 13: Navigation render-assertion hardening", "status": "pending"},
|
{"id": 13, "nativeId": 70, "subject": "Task 13: Navigation render-assertion hardening", "status": "completed"},
|
||||||
{"id": 14, "nativeId": 71, "subject": "Task 14: Health KPI load test", "status": "pending", "blockedBy": [1]},
|
{"id": 14, "nativeId": 71, "subject": "Task 14: Health KPI load test", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 15, "nativeId": 72, "subject": "Task 15: Full-suite verification + no-residue check", "status": "pending", "blockedBy": [3, 7, 8, 9, 10, 11, 12, 13, 14]}
|
{"id": 15, "nativeId": 72, "subject": "Task 15: Full-suite verification + no-residue check", "status": "completed", "blockedBy": [3, 7, 8, 9, 10, 11, 12, 13, 14]}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,10 @@ Wire up behavior that exists in code but is never started, and fill the event-lo
|
|||||||
#### M5 — Audit hardening (T1–T8)
|
#### M5 — Audit hardening (T1–T8)
|
||||||
Hash-chain tamper evidence (off by default, `verify-chain` made real); Parquet export/archival (replace the 501); per-channel retention overrides; tag-cascade for `ParentExecutionId` (thread writing-execution id through trigger-driven runs); ExecutionId/ParentExecutionId + SourceNode backfill on historical rows; per-node stuck-count KPIs; structured response capture (headers/content-type, inbound request headers, per-method opt-out, `AuditInboundCeilingHits` metric); CLI `audit tree`.
|
Hash-chain tamper evidence (off by default, `verify-chain` made real); Parquet export/archival (replace the 501); per-channel retention overrides; tag-cascade for `ParentExecutionId` (thread writing-execution id through trigger-driven runs); ExecutionId/ParentExecutionId + SourceNode backfill on historical rows; per-node stuck-count KPIs; structured response capture (headers/content-type, inbound request headers, per-method opt-out, `AuditInboundCeilingHits` metric); CLI `audit tree`.
|
||||||
|
|
||||||
#### M6 — KPI History & Trends (T11 delivered; T9/T10 deferred)
|
#### M6 — KPI History & Trends (T11 delivered; T9/T10 **since DELIVERED 2026-06-19**)
|
||||||
|
|
||||||
|
> **2026-08-01 bookkeeping sync:** the T9/T10 "deferred to the next major version" text below is superseded — **T9/T10 were delivered 2026-06-19 as the SMS (Twilio) adapter** (`SmsNotificationDeliveryAdapter`, `NotificationType.Sms`, Central UI Type selector + SMS recipient input; see `docs/plans/2026-06-19-sms-notifications.md`). **Teams was evaluated and dropped by design decision** — outbound-only backends cannot send 1:1 chat bodies via Graph without a Bot Framework inbound endpoint; SMS is inherently per-person and outbound-only.
|
||||||
|
|
||||||
Reshaped during the 2026-06-17 brainstorm (see `docs/plans/2026-06-17-m6-kpi-history-design.md`):
|
Reshaped during the 2026-06-17 brainstorm (see `docs/plans/2026-06-17-m6-kpi-history-design.md`):
|
||||||
- **T11 — DELIVERED** as the reusable **KPI-history backbone** (#26 KpiHistory), promoted from a notifications-only feature. A tall/EAV `KpiSample` store in **central MS SQL** (no new infra — supersedes the original "point-in-time only, no time-series store" stance), a `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, not readiness-gated, best-effort with per-source isolation) sampling DI-registered `IKpiSampleSource`s every minute, a bucketed `GetRawSeriesAsync` + `KpiSeriesBucketer` query + scoped `KpiHistoryQueryService`, and a reusable custom-SVG `KpiTrendChart` (no third-party charting lib). Trends shipped for **all** current KPI sources — Notification Outbox, Site Call Audit, Audit Log, and Site Health — across four UI surfaces.
|
- **T11 — DELIVERED** as the reusable **KPI-history backbone** (#26 KpiHistory), promoted from a notifications-only feature. A tall/EAV `KpiSample` store in **central MS SQL** (no new infra — supersedes the original "point-in-time only, no time-series store" stance), a `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, not readiness-gated, best-effort with per-source isolation) sampling DI-registered `IKpiSampleSource`s every minute, a bucketed `GetRawSeriesAsync` + `KpiSeriesBucketer` query + scoped `KpiHistoryQueryService`, and a reusable custom-SVG `KpiTrendChart` (no third-party charting lib). Trends shipped for **all** current KPI sources — Notification Outbox, Site Call Audit, Audit Log, and Site Health — across four UI surfaces.
|
||||||
- **T9 (Teams + other non-Email delivery adapters behind `INotificationDeliveryAdapter`) — DEFERRED to the next major version.** The seam exists; no code now. Transport choice (Incoming Webhook vs Microsoft Graph) and the Teams list-targeting model remain to be designed.
|
- **T9 (Teams + other non-Email delivery adapters behind `INotificationDeliveryAdapter`) — DEFERRED to the next major version.** The seam exists; no code now. Transport choice (Incoming Webhook vs Microsoft Graph) and the Teams list-targeting model remain to be designed.
|
||||||
@@ -132,7 +135,7 @@ This is the **final milestone** of the system-completion roadmap. All in-scope M
|
|||||||
## Dependencies & sequencing
|
## Dependencies & sequencing
|
||||||
|
|
||||||
- **M1 → M5** — audit hardening builds on the wired purge/reconciliation.
|
- **M1 → M5** — audit hardening builds on the wired purge/reconciliation.
|
||||||
- **M6/T11** — delivered as the #26 KpiHistory backbone; reused **central MS SQL** (a tall/EAV `KpiSample` table) rather than introducing new infra. T9/T10 deferred to the next major version.
|
- **M6/T11** — delivered as the #26 KpiHistory backbone; reused **central MS SQL** (a tall/EAV `KpiSample` table) rather than introducing new infra. T9/T10 since delivered 2026-06-19 as the SMS (Twilio) adapter (see note above).
|
||||||
- **M9/T26** — base-template versioning is the largest authoring item; may split.
|
- **M9/T26** — base-template versioning is the largest authoring item; may split.
|
||||||
- **M4** — runs anytime; cheap and high-clarity, good to interleave.
|
- **M4** — runs anytime; cheap and high-clarity, good to interleave.
|
||||||
- **M3** — independent; can run in parallel with M1/M2.
|
- **M3** — independent; can run in parallel with M1/M2.
|
||||||
|
|||||||
@@ -27,9 +27,10 @@
|
|||||||
{"ref": "#19", "subject": "script started/completed events", "status": "done in M1.8"}
|
{"ref": "#19", "subject": "script started/completed events", "status": "done in M1.8"}
|
||||||
],
|
],
|
||||||
"followups": [
|
"followups": [
|
||||||
{"id": 52, "subject": "Investigate 2 partition-purge E2E test failures (AuditLogPurgeActor/PartitionPurge)", "from": "M2.0", "status": "pending"},
|
{"id": 52, "subject": "Investigate 2 partition-purge E2E test failures (AuditLogPurgeActor/PartitionPurge)", "from": "M2.0", "status": "resolved", "note": "RESOLVED — commit 639e331d de-dated the 2 EndToEnd purge tests (M5.7)"},
|
||||||
{"id": 53, "subject": "Dedup alarm-capable protocol predicate (3 copies → AlarmCapableProtocols)", "from": "M2.1", "status": "pending"},
|
{"id": 53, "subject": "Dedup alarm-capable protocol predicate (3 copies → AlarmCapableProtocols)", "from": "M2.1", "status": "resolved", "note": "RESOLVED — shared Commons/Interfaces/Protocol/AlarmCapableProtocols.cs is now the single predicate"},
|
||||||
{"id": 54, "subject": "Expose ExecutionTimeoutSeconds (+ MinTimeBetweenRuns) in CLI + UI script authoring", "from": "M2.5", "status": "pending"}
|
{"id": 54, "subject": "Expose ExecutionTimeoutSeconds (+ MinTimeBetweenRuns) in CLI + UI script authoring", "from": "M2.5", "status": "resolved", "note": "RESOLVED — CLI --execution-timeout-seconds on template script add/update plus TemplateEdit binding"}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-06-15"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-06-15-stillpending-phase1-implementation.md",
|
"planPath": "docs/plans/2026-06-15-stillpending-phase1-implementation.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 22, "subject": "M1.0: Confirm proto/site surface for audit pull (spike)", "status": "pending"},
|
{"id": 22, "subject": "M1.0: Confirm proto/site surface for audit pull (spike)", "status": "completed"},
|
||||||
{"id": 23, "subject": "M1.1: Production gRPC IPullAuditEventsClient", "status": "pending", "blockedBy": [22]},
|
{"id": 23, "subject": "M1.1: Production gRPC IPullAuditEventsClient", "status": "completed", "blockedBy": [22]},
|
||||||
{"id": 24, "subject": "M1.2: Wire reconciliation + purge actors as central singletons", "status": "pending"},
|
{"id": 24, "subject": "M1.2: Wire reconciliation + purge actors as central singletons", "status": "completed"},
|
||||||
{"id": 25, "subject": "M1.3: SiteCallAudit periodic reconciliation pull", "status": "pending"},
|
{"id": 25, "subject": "M1.3: SiteCallAudit periodic reconciliation pull", "status": "completed"},
|
||||||
{"id": 26, "subject": "M1.4: SiteCallAudit daily terminal-row purge scheduler", "status": "pending"},
|
{"id": 26, "subject": "M1.4: SiteCallAudit daily terminal-row purge scheduler", "status": "completed"},
|
||||||
{"id": 27, "subject": "M1.5: SiteEventLog — emit Alarm events", "status": "pending"},
|
{"id": 27, "subject": "M1.5: SiteEventLog — emit Alarm events", "status": "completed"},
|
||||||
{"id": 28, "subject": "M1.6: SiteEventLog — Deployment + Instance-lifecycle events", "status": "pending"},
|
{"id": 28, "subject": "M1.6: SiteEventLog — Deployment + Instance-lifecycle events", "status": "completed"},
|
||||||
{"id": 29, "subject": "M1.7: SiteEventLog — Store-and-Forward + Notification events", "status": "pending"},
|
{"id": 29, "subject": "M1.7: SiteEventLog — Store-and-Forward + Notification events", "status": "completed"},
|
||||||
{"id": 30, "subject": "M1.8: SiteEventLog — script started/completed (Info)", "status": "pending"},
|
{"id": 30, "subject": "M1.8: SiteEventLog — script started/completed (Info)", "status": "completed"},
|
||||||
{"id": 31, "subject": "M1.9: M1 integration verification + redeploy", "status": "pending", "blockedBy": [23, 24, 25, 26, 27, 28, 29, 30]},
|
{"id": 31, "subject": "M1.9: M1 integration verification + redeploy", "status": "completed", "blockedBy": [23, 24, 25, 26, 27, 28, 29, 30]},
|
||||||
{"id": 13, "subject": "M2 — Correctness & behavioral gaps (Tier 2) [umbrella; split per-item at execution]", "status": "pending"},
|
{"id": 13, "subject": "M2 — Correctness & behavioral gaps (Tier 2) [umbrella; split per-item at execution]", "status": "completed"},
|
||||||
{"id": 14, "subject": "M3 — Script trust boundary (Tier 1 #1-#2) [umbrella]", "status": "pending"},
|
{"id": 14, "subject": "M3 — Script trust boundary (Tier 1 #1-#2) [umbrella]", "status": "completed"},
|
||||||
{"id": 15, "subject": "M4 — Doc reconciliation (Tier 4) [umbrella]", "status": "pending"}
|
{"id": 15, "subject": "M4 — Doc reconciliation (Tier 4) [umbrella]", "status": "completed"}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-06-15"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-06-16-ipsen-mes-movein.md",
|
"planPath": "docs/plans/2026-06-16-ipsen-mes-movein.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 1, "subject": "Task 1: InboundDatabaseHelper (read-only DB for inbound scripts)", "classification": "standard", "status": "pending"},
|
{"id": 1, "subject": "Task 1: InboundDatabaseHelper (read-only DB for inbound scripts)", "classification": "standard", "status": "completed"},
|
||||||
{"id": 2, "subject": "Task 2: Expose Database to inbound scripts (context + executor)", "classification": "high-risk", "status": "pending", "blockedBy": [1]},
|
{"id": 2, "subject": "Task 2: Expose Database to inbound scripts (context + executor)", "classification": "high-risk", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 3, "subject": "Task 3: Build + regression-test ScadaBridge", "classification": "small", "status": "pending", "blockedBy": [2]},
|
{"id": 3, "subject": "Task 3: Build + regression-test ScadaBridge", "classification": "small", "status": "completed", "blockedBy": [2]},
|
||||||
{"id": 4, "subject": "Task 4: Commit Component A", "classification": "trivial", "status": "pending", "blockedBy": [3]},
|
{"id": 4, "subject": "Task 4: Commit Component A", "classification": "trivial", "status": "completed", "blockedBy": [3]},
|
||||||
{"id": 5, "subject": "Task 5: Deploy ScadaBridge Central (Component A) to wonder-app-vd03", "classification": "high-risk", "status": "pending", "blockedBy": [4]},
|
{"id": 5, "subject": "Task 5: Deploy ScadaBridge Central (Component A) to wonder-app-vd03", "classification": "high-risk", "status": "completed", "note": "done off-repo on wonder-app-vd03 (live methods authored against Database.* fixed there 2026-06-25)", "blockedBy": [4]},
|
||||||
{"id": 6, "subject": "Task 6: Apply Component B - update inbound IpsenMESMoveIn script", "classification": "high-risk", "status": "pending", "blockedBy": [5]},
|
{"id": 6, "subject": "Task 6: Apply Component B - update inbound IpsenMESMoveIn script", "classification": "high-risk", "status": "completed", "note": "done off-repo on wonder-app-vd03 (live methods authored against Database.* fixed there 2026-06-25)", "blockedBy": [5]},
|
||||||
{"id": 7, "subject": "Task 7: Apply Component C - IpsenMoveIn template script on T1", "classification": "high-risk", "status": "pending", "blockedBy": [5]},
|
{"id": 7, "subject": "Task 7: Apply Component C - IpsenMoveIn template script on T1", "classification": "high-risk", "status": "pending", "note": "NEEDS LIVE vd03 verification — tracked in the 2026-08-01 pending-work task list", "blockedBy": [5]},
|
||||||
{"id": 8, "subject": "Task 8: On-box end-to-end verification", "classification": "standard", "status": "pending", "blockedBy": [6, 7]}
|
{"id": 8, "subject": "Task 8: On-box end-to-end verification", "classification": "standard", "status": "pending", "note": "NEEDS LIVE vd03 verification — tracked in the 2026-08-01 pending-work task list", "blockedBy": [6, 7]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-06-16"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-06-16-m5-audit-hardening.md",
|
"planPath": "docs/plans/2026-06-16-m5-audit-hardening.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 119, "subject": "M5.1 (T8): CLI audit tree + tree endpoint", "status": "pending"},
|
{"id": 119, "subject": "M5.1 (T8): CLI audit tree + tree endpoint", "status": "completed"},
|
||||||
{"id": 120, "subject": "M5.2 (T6): Per-node stuck-count KPIs", "status": "pending"},
|
{"id": 120, "subject": "M5.2 (T6): Per-node stuck-count KPIs", "status": "completed"},
|
||||||
{"id": 121, "subject": "M5.3 (T7): Structured response-capture increments", "status": "pending"},
|
{"id": 121, "subject": "M5.3 (T7): Structured response-capture increments", "status": "completed"},
|
||||||
{"id": 122, "subject": "M5.4 (T4): ParentExecutionId tag-cascade", "status": "pending"},
|
{"id": 122, "subject": "M5.4 (T4): ParentExecutionId tag-cascade", "status": "completed"},
|
||||||
{"id": 123, "subject": "M5.5 (T3): Per-channel retention overrides", "status": "pending"},
|
{"id": 123, "subject": "M5.5 (T3): Per-channel retention overrides", "status": "completed"},
|
||||||
{"id": 124, "subject": "M5.6 (T5): SourceNode sentinel backfill + runbook", "status": "pending", "blockedBy": [119]},
|
{"id": 124, "subject": "M5.6 (T5): SourceNode sentinel backfill + runbook", "status": "completed", "blockedBy": [119]},
|
||||||
{"id": 125, "subject": "M5.7: M5 integration verification + docs", "status": "pending", "blockedBy": [119, 120, 121, 122, 123, 124]}
|
{"id": 125, "subject": "M5.7: M5 integration verification + docs", "status": "completed", "blockedBy": [119, 120, 121, 122, 123, 124]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-06-16"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-06-16-script-analysis-consolidation.md",
|
"planPath": "docs/plans/2026-06-16-script-analysis-consolidation.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 103, "subject": "M3.0: Spike — compile-surface stub feasibility + package refs", "status": "pending"},
|
{"id": 103, "subject": "M3.0: Spike — compile-surface stub feasibility + package refs", "status": "completed"},
|
||||||
{"id": 104, "subject": "M3.1: Build ScriptAnalysis project (policy+validator+compiler+surfaces+tests)", "status": "pending", "blockedBy": [103]},
|
{"id": 104, "subject": "M3.1: Build ScriptAnalysis project (policy+validator+compiler+surfaces+tests)", "status": "completed", "blockedBy": [103]},
|
||||||
{"id": 105, "subject": "M3.2: TemplateEngine deploy gate → shared analyzer", "status": "pending", "blockedBy": [104]},
|
{"id": 105, "subject": "M3.2: TemplateEngine deploy gate → shared analyzer", "status": "completed", "blockedBy": [104]},
|
||||||
{"id": 106, "subject": "M3.3: SiteRuntime → shared analyzer + parity test", "status": "pending", "blockedBy": [104]},
|
{"id": 106, "subject": "M3.3: SiteRuntime → shared analyzer + parity test", "status": "completed", "blockedBy": [104]},
|
||||||
{"id": 107, "subject": "M3.4: InboundAPI → shared analyzer", "status": "pending", "blockedBy": [104]},
|
{"id": 107, "subject": "M3.4: InboundAPI → shared analyzer", "status": "completed", "blockedBy": [104]},
|
||||||
{"id": 108, "subject": "M3.5: CentralUI → shared analyzer (keep markers + Test-Run host)", "status": "pending", "blockedBy": [104]},
|
{"id": 108, "subject": "M3.5: CentralUI → shared analyzer (keep markers + Test-Run host)", "status": "completed", "blockedBy": [104]},
|
||||||
{"id": 109, "subject": "M3.6: Integration verification + docs + fixture cleanup", "status": "pending", "blockedBy": [105, 106, 107, 108]}
|
{"id": 109, "subject": "M3.6: Integration verification + docs + fixture cleanup", "status": "completed", "blockedBy": [105, 106, 107, 108]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-06-16"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-06-17-debugview-tabs-trees.md",
|
"planPath": "docs/plans/2026-06-17-debugview-tabs-trees.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 170, "subject": "DV-1: Native-binding linkage — additive AlarmStateChanged contract chain", "classification": "high-risk", "status": "pending"},
|
{"id": 170, "subject": "DV-1: Native-binding linkage — additive AlarmStateChanged contract chain", "classification": "high-risk", "status": "completed"},
|
||||||
{"id": 171, "subject": "DV-2: Site snapshot — placeholder rows for idle native source bindings", "classification": "standard", "status": "pending", "blockedBy": [170]},
|
{"id": 171, "subject": "DV-2: Site snapshot — placeholder rows for idle native source bindings", "classification": "standard", "status": "completed", "blockedBy": [170]},
|
||||||
{"id": 172, "subject": "DV-3: DebugTreeNode model + attribute-tree builder", "classification": "standard", "status": "pending", "blockedBy": [170]},
|
{"id": 172, "subject": "DV-3: DebugTreeNode model + attribute-tree builder", "classification": "standard", "status": "completed", "blockedBy": [170]},
|
||||||
{"id": 173, "subject": "DV-4: Alarm-tree builder — computed leaves + native binding grouping + roll-up", "classification": "standard", "status": "pending", "blockedBy": [172, 170]},
|
{"id": 173, "subject": "DV-4: Alarm-tree builder — computed leaves + native binding grouping + roll-up", "classification": "standard", "status": "completed", "blockedBy": [172, 170]},
|
||||||
{"id": 174, "subject": "DV-5: DebugView page — tabs + two TreeViews + in-place updates", "classification": "standard", "status": "pending", "blockedBy": [173]},
|
{"id": 174, "subject": "DV-5: DebugView page — tabs + two TreeViews + in-place updates", "classification": "standard", "status": "completed", "blockedBy": [173]},
|
||||||
{"id": 175, "subject": "DV-6: Documentation — CentralUI + SiteRuntime + streaming contract", "classification": "small", "status": "pending", "blockedBy": [173]},
|
{"id": 175, "subject": "DV-6: Documentation — CentralUI + SiteRuntime + streaming contract", "classification": "small", "status": "completed", "blockedBy": [173]},
|
||||||
{"id": 176, "subject": "DV-7: Integration — full build, docker rebuild, Playwright, smoke", "classification": "high-risk", "status": "pending", "blockedBy": [174, 175, 171]}
|
{"id": 176, "subject": "DV-7: Integration — full build, docker rebuild, Playwright, smoke", "classification": "high-risk", "status": "completed", "blockedBy": [174, 175, 171]}
|
||||||
],
|
],
|
||||||
"waves": [
|
"waves": [
|
||||||
{"wave": 1, "tasks": [170]},
|
{"wave": 1, "tasks": [170]},
|
||||||
@@ -16,5 +16,6 @@
|
|||||||
{"wave": 4, "tasks": [174, 175]},
|
{"wave": 4, "tasks": [174, 175]},
|
||||||
{"wave": 5, "tasks": [176]}
|
{"wave": 5, "tasks": [176]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-06-17"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-06-17-m6-kpi-history.md",
|
"planPath": "docs/plans/2026-06-17-m6-kpi-history.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 1, "subject": "K1: Foundation contracts (Commons)", "nativeId": 136, "classification": "high-risk", "status": "pending"},
|
{"id": 1, "subject": "K1: Foundation contracts (Commons)", "nativeId": 136, "classification": "high-risk", "status": "completed"},
|
||||||
{"id": 2, "subject": "K2: Persistence — EF config + repository + migration", "nativeId": 137, "classification": "high-risk", "status": "pending", "blockedBy": [1]},
|
{"id": 2, "subject": "K2: Persistence — EF config + repository + migration", "nativeId": 137, "classification": "high-risk", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 3, "subject": "K3: KpiHistory project scaffold + options", "nativeId": 138, "classification": "standard", "status": "pending", "blockedBy": [1]},
|
{"id": 3, "subject": "K3: KpiHistory project scaffold + options", "nativeId": 138, "classification": "standard", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 4, "subject": "K4: KpiHistoryRecorderActor", "nativeId": 139, "classification": "high-risk", "status": "pending", "blockedBy": [3]},
|
{"id": 4, "subject": "K4: KpiHistoryRecorderActor", "nativeId": 139, "classification": "high-risk", "status": "completed", "blockedBy": [3]},
|
||||||
{"id": 5, "subject": "K5: Host wiring + appsettings", "nativeId": 140, "classification": "high-risk", "status": "pending", "blockedBy": [4]},
|
{"id": 5, "subject": "K5: Host wiring + appsettings", "nativeId": 140, "classification": "high-risk", "status": "completed", "blockedBy": [4]},
|
||||||
{"id": 6, "subject": "K6: NotificationOutboxKpiSampleSource", "nativeId": 141, "classification": "small", "status": "pending", "blockedBy": [1]},
|
{"id": 6, "subject": "K6: NotificationOutboxKpiSampleSource", "nativeId": 141, "classification": "small", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 7, "subject": "K7: SiteCallAuditKpiSampleSource", "nativeId": 142, "classification": "small", "status": "pending", "blockedBy": [1]},
|
{"id": 7, "subject": "K7: SiteCallAuditKpiSampleSource", "nativeId": 142, "classification": "small", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 8, "subject": "K8: AuditLogKpiSampleSource", "nativeId": 143, "classification": "small", "status": "pending", "blockedBy": [1]},
|
{"id": 8, "subject": "K8: AuditLogKpiSampleSource", "nativeId": 143, "classification": "small", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 9, "subject": "K9: SiteHealthKpiSampleSource", "nativeId": 144, "classification": "standard", "status": "pending", "blockedBy": [1]},
|
{"id": 9, "subject": "K9: SiteHealthKpiSampleSource", "nativeId": 144, "classification": "standard", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 10, "subject": "K10: KpiSeriesBucketer (pure helper)", "nativeId": 145, "classification": "small", "status": "pending", "blockedBy": [1]},
|
{"id": 10, "subject": "K10: KpiSeriesBucketer (pure helper)", "nativeId": 145, "classification": "small", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 11, "subject": "K11: KpiHistoryQueryService (CentralUI)", "nativeId": 146, "classification": "standard", "status": "pending", "blockedBy": [2, 10]},
|
{"id": 11, "subject": "K11: KpiHistoryQueryService (CentralUI)", "nativeId": 146, "classification": "standard", "status": "completed", "blockedBy": [2, 10]},
|
||||||
{"id": 12, "subject": "K12: KpiTrendChart.razor reusable SVG component", "nativeId": 147, "classification": "standard", "status": "pending", "blockedBy": [1]},
|
{"id": 12, "subject": "K12: KpiTrendChart.razor reusable SVG component", "nativeId": 147, "classification": "standard", "status": "completed", "blockedBy": [1]},
|
||||||
{"id": 13, "subject": "K13: Notification Outbox page trend section", "nativeId": 148, "classification": "standard", "status": "pending", "blockedBy": [11, 12]},
|
{"id": 13, "subject": "K13: Notification Outbox page trend section", "nativeId": 148, "classification": "standard", "status": "completed", "blockedBy": [11, 12]},
|
||||||
{"id": 14, "subject": "K14: Site Calls page trend section", "nativeId": 149, "classification": "standard", "status": "pending", "blockedBy": [11, 12]},
|
{"id": 14, "subject": "K14: Site Calls page trend section", "nativeId": 149, "classification": "standard", "status": "completed", "blockedBy": [11, 12]},
|
||||||
{"id": 15, "subject": "K15: Audit Log page trend section", "nativeId": 150, "classification": "standard", "status": "pending", "blockedBy": [11, 12]},
|
{"id": 15, "subject": "K15: Audit Log page trend section", "nativeId": 150, "classification": "standard", "status": "completed", "blockedBy": [11, 12]},
|
||||||
{"id": 16, "subject": "K16: Health dashboard per-site trend panel", "nativeId": 151, "classification": "standard", "status": "pending", "blockedBy": [11, 12]},
|
{"id": 16, "subject": "K16: Health dashboard per-site trend panel", "nativeId": 151, "classification": "standard", "status": "completed", "blockedBy": [11, 12]},
|
||||||
{"id": 17, "subject": "K17: Integration — docs, deploy, Playwright, full verification", "nativeId": 152, "classification": "high-risk", "status": "pending", "blockedBy": [5, 6, 7, 8, 9, 13, 14, 15, 16]}
|
{"id": 17, "subject": "K17: Integration — docs, deploy, Playwright, full verification", "nativeId": 152, "classification": "high-risk", "status": "completed", "blockedBy": [5, 6, 7, 8, 9, 13, 14, 15, 16]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-06-17"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
],
|
],
|
||||||
"reviewPolishCommit": "8dcc55f",
|
"reviewPolishCommit": "8dcc55f",
|
||||||
"followUps": [
|
"followUps": [
|
||||||
{"id": 162, "subject": "normalize routed GetAttributes List values for cross-process transport (surfaced by WS-4 review)"}
|
{"id": 162, "subject": "normalize routed GetAttributes List values for cross-process transport (surfaced by WS-4 review)", "status": "resolved", "note": "RESOLVED — DeploymentManagerActor.cs RouteInboundApiGetAttributes now normalizes via NormalizeRoutedReturnValue"}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-06-17"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,7 +239,9 @@ fully cover the DELMIA/MES use case.
|
|||||||
> **Status:** IMPLEMENTED. `Route.To(code).WaitForAttribute(name, targetValue, timeout)` is wired
|
> **Status:** IMPLEMENTED. `Route.To(code).WaitForAttribute(name, targetValue, timeout)` is wired
|
||||||
> end-to-end (`RouteToWaitForAttributeRequest/Response` → `IInstanceRouter` → `CommunicationService`
|
> end-to-end (`RouteToWaitForAttributeRequest/Response` → `IInstanceRouter` → `CommunicationService`
|
||||||
> → `SiteCommunicationActor` → `DeploymentManagerActor` → `InstanceActor`), value-equality only
|
> → `SiteCommunicationActor` → `DeploymentManagerActor` → `InstanceActor`), value-equality only
|
||||||
> across the wire. NOT wired into the CentralUI Test-Run sandbox — that remains a follow-up.
|
> across the wire. ~~NOT wired into the CentralUI Test-Run sandbox — that remains a follow-up.~~
|
||||||
|
> **Follow-up SHIPPED 2026-07-10** — the CentralUI Test-Run sandbox is wired via
|
||||||
|
> `SandboxInstanceGateway`/`SandboxScriptHost` (deferred-work register row #14 resolved).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-06-17-waitfor-deferred-items.md",
|
"planPath": "docs/plans/2026-06-17-waitfor-deferred-items.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 1, "subject": "WD-1: site-local WaitForAsync + WaitResult + quality-gated mode (§3+§4.2)", "classification": "high-risk", "status": "pending", "parallelizableWith": [2]},
|
{"id": 1, "subject": "WD-1: site-local WaitForAsync + WaitResult + quality-gated mode (§3+§4.2)", "classification": "high-risk", "status": "completed", "parallelizableWith": [2]},
|
||||||
{"id": 2, "subject": "WD-2a: routed contract + central path (§6 part 1)", "classification": "high-risk", "status": "pending", "parallelizableWith": [1]},
|
{"id": 2, "subject": "WD-2a: routed contract + central path (§6 part 1)", "classification": "high-risk", "status": "completed", "parallelizableWith": [1]},
|
||||||
{"id": 3, "subject": "WD-2b: site unpacking + DeploymentManager handler (§6 part 2)", "classification": "high-risk", "status": "pending", "blockedBy": [2]},
|
{"id": 3, "subject": "WD-2b: site unpacking + DeploymentManager handler (§6 part 2)", "classification": "high-risk", "status": "completed", "blockedBy": [2]},
|
||||||
{"id": 4, "subject": "WD-3: integration — docs + full verification", "classification": "standard", "status": "pending", "blockedBy": [1, 2, 3]}
|
{"id": 4, "subject": "WD-3: integration — docs + full verification", "classification": "standard", "status": "completed", "blockedBy": [1, 2, 3]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-06-17"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -459,15 +459,15 @@ Markup: two `<input type="datetime-local">` with `id="@(IdPrefix)-from"`/`-to`,
|
|||||||
## Deferred / out of scope (log as FOLLOWUPs at INT)
|
## Deferred / out of scope (log as FOLLOWUPs at INT)
|
||||||
- Unified offset+keyset pagination framework (blocked by total-count mismatch).
|
- Unified offset+keyset pagination framework (blocked by total-count mismatch).
|
||||||
- `Deployments.razor` PagerWindow → OffsetPager (different windowed UX; intentionally left).
|
- `Deployments.razor` PagerWindow → OffsetPager (different windowed UX; intentionally left).
|
||||||
- Complex `TemplateEdit` page-embedded modals → host migration.
|
- ~~Complex `TemplateEdit` page-embedded modals → host migration.~~ **DONE 2026-08-01.**
|
||||||
- TreeView arrow-key navigation (R7).
|
- ~~TreeView arrow-key navigation (R7).~~ **DONE 2026-08-01.**
|
||||||
- Theme-package side-rail dark theming, IF the spike verdict is "rail stays light" (coordination follow-up).
|
- Theme-package side-rail dark theming, IF the spike verdict is "rail stays light" (coordination follow-up).
|
||||||
|
|
||||||
## Follow-ups logged at delivery (INT findings)
|
## Follow-ups logged at delivery (INT findings)
|
||||||
- **#207 (pre-existing, open since M6/K14):** `QueryStringDrillInTests` fixture does not register `IKpiHistoryQueryService` — 3 `SiteCallsReport` drill-in tests red on this gap; unrelated to M10.
|
- **#207 — FIXED (2026-08-01 bookkeeping sync).** ~~(pre-existing, open since M6/K14):~~ `QueryStringDrillInTests` now registers the `IKpiHistoryQueryService` substitute in its fixture (lines ~165-167), so the 3 `SiteCallsReport` drill-in tests are green.
|
||||||
- **#163 (pre-existing):** `InstanceConfigureListOverrideTests` codec roundtrip red; pre-dates M10.
|
- **#163 — CLOSED (2026-08-01).** ~~(pre-existing):~~ `InstanceConfigureListOverrideTests` codec roundtrip verified green (6/6; `dotnet test …CentralUI.Tests --filter InstanceConfigureListOverride`) — fixed at some point since it was logged; no longer a known red. (#207 `QueryStringDrillInTests` re-verified green the same day, 4/4.)
|
||||||
- **NotificationReport `OffsetPager` always-visible:** pager is now always visible when results exist (previously hidden on sub-page-size sets); buttons are correctly disabled on a single page — product decision whether to re-add an `@if (_totalCount > _pageSize)` guard.
|
- **NotificationReport `OffsetPager` always-visible — DECIDED + DONE (2026-08-01).** A pager whose only controls are permanently disabled is noise, so the conventional behaviour ships: the bar is hidden on a provably single page. Implemented **reusably but opt-in** — `OffsetPager.HideWhenSinglePage` (default `false`, so no consumer changes behaviour implicitly), set `true` at the `NotificationReport` call site. It is NOT defaulted on because the pager's summary span doubles as the "N total" result-count readout on `ConfigurationAuditLog`, which has no other place to show it (and whose Playwright fixture asserts `3 total` on a 3-row single page). The guard only fires when single-page-ness is *positively* established (`PageCount <= 1` **and** `Page <= 1` **and** `!HasNextPage`), so a null `TotalCount` still renders the bar.
|
||||||
- **`Deployments.razor` PagerWindow intentionally kept:** windowed numbered-button UX is deliberate; NOT migrated to `OffsetPager`.
|
- **`Deployments.razor` PagerWindow intentionally kept:** windowed numbered-button UX is deliberate; NOT migrated to `OffsetPager`.
|
||||||
- **`TemplateEdit` inline modals NOT migrated:** the page-embedded modals (wave-3 T34c already tokenized their backdrops); full migration to the host is deferred.
|
- **`TemplateEdit` inline modals — MIGRATED (2026-08-01).** All four page-embedded modals (Attribute, Alarm, Native Alarm Source, Script) now open through `IDialogService.ShowAsync`, so the host owns the backdrop, focus trap, Escape and focus restoration. Each body was extracted to its own component next to the page — `TemplateAttributeDialog`, `TemplateAlarmDialog`, `TemplateNativeAlarmSourceDialog`, `TemplateScriptDialog` — following the `MoveDataConnectionDialog` pattern: the body owns the form state and renders validation/server errors INLINE while staying open, closing with `Close(true)` only on a successful save. Persistence stayed on the page (it owns `TemplateService` and the inherited-member rules) and is reached through an `OnSaveAsync` delegate returning `null` on success or the message to display. The script dialog keeps all four tab panels mounted (Monaco/JSONJoy must not tear down on tab switch) and hosts the Test Run panel, which needs the live unsaved buffer. Extraction moved markup that three structural source-scanning tests pinned (`TemplateNativeAlarmSourceEditorTests`, `AttributeListEditorTests`, `TestRunWarningTests`) — all three were repointed at the new files.
|
||||||
- **TreeView full arrow-key navigation (R7) still deferred.**
|
- **~~TreeView full arrow-key navigation (R7) still deferred.~~ DONE 2026-08-01** — the WAI-ARIA tree pattern (roving tabindex, Arrow/Home/End movement, Enter/Space activation, `aria-level`) now ships on `TreeView<TItem>`; see `docs/components/TreeView.md`.
|
||||||
- **Full-app `bg-light`/`bg-white` → theme-aware utility sweep deferred:** only the bounded modal-surface offenders were addressed in T34c; INT dark-mode smoke may surface additional instances.
|
- **Full-app `bg-light`/`bg-white` → theme-aware utility sweep — DONE 2026-08-01.** 35 class swaps across 19 Central UI files: surfaces/`<pre>`/`<code>` `bg-light` → `bg-body-secondary`, panel `bg-white` → `bg-body`, neutral `badge bg-light text-dark` → `badge bg-secondary-subtle text-secondary-emphasis`, `bg-light text-muted border` → `bg-body-secondary text-body-secondary border`. **Deliberately left** (7 sites): the neutral member of a *status-badge* switch/ternary whose siblings are all solid non-theme-aware colours (`bg-success`/`bg-danger`/`bg-warning`), where swapping only the neutral one breaks the set's visual weight — `Topology.razor:513,588`, `InstanceConfigure.razor:1520`, `NotificationReport.razor` `StatusBadgeClass` fallback, `TransportImport.razor` ConflictKind fallback, `SecuredWrites.razor` `text-bg-light` fallback, `Health.razor:377`. `SchemaBuilder.razor:88` was already `bg-light-subtle` (theme-aware). `bg-dark text-light` console panels and `site.css`/`#reconnect-modal` are intentional fixed colours.
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-06-18-m10-uiux-platform.md",
|
"planPath": "docs/plans/2026-06-18-m10-uiux-platform.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 269, "subject": "M10-T33a: Modal host ShowAsync<T> + focus trap/restore + backdrop hook", "status": "pending"},
|
{"id": 269, "subject": "M10-T33a: Modal host ShowAsync<T> + focus trap/restore + backdrop hook", "status": "completed"},
|
||||||
{"id": 270, "subject": "M10-T34-spike: Verify ZB.MOM.WW.Theme dark-mode feasibility", "status": "pending"},
|
{"id": 270, "subject": "M10-T34-spike: Verify ZB.MOM.WW.Theme dark-mode feasibility", "status": "completed"},
|
||||||
{"id": 271, "subject": "M10-T41: Alarm-override Playwright trigger-config scenarios", "status": "pending"},
|
{"id": 271, "subject": "M10-T41: Alarm-override Playwright trigger-config scenarios", "status": "completed"},
|
||||||
{"id": 272, "subject": "M10-T33b: Migrate 5 simple dialogs to ShowAsync<T>", "status": "pending", "blockedBy": [269]},
|
{"id": 272, "subject": "M10-T33b: Migrate 5 simple dialogs to ShowAsync<T>", "status": "completed", "blockedBy": [269]},
|
||||||
{"id": 273, "subject": "M10-T34a: Dark token layer in site.css + .sb-modal-backdrop", "status": "pending", "blockedBy": [270]},
|
{"id": 273, "subject": "M10-T34a: Dark token layer in site.css + .sb-modal-backdrop", "status": "completed", "blockedBy": [270]},
|
||||||
{"id": 274, "subject": "M10-T35a: OffsetPager component", "status": "pending"},
|
{"id": 274, "subject": "M10-T35a: OffsetPager component", "status": "completed"},
|
||||||
{"id": 275, "subject": "M10-T35b: KeysetPager component", "status": "pending"},
|
{"id": 275, "subject": "M10-T35b: KeysetPager component", "status": "completed"},
|
||||||
{"id": 276, "subject": "M10-T35c: DateTimeRangeFilter component", "status": "pending"},
|
{"id": 276, "subject": "M10-T35c: DateTimeRangeFilter component", "status": "completed"},
|
||||||
{"id": 277, "subject": "M10-T35d: Adopt OffsetPager+DateTimeRangeFilter into NotificationReport (+backdrop token)", "status": "pending", "blockedBy": [274, 276, 273]},
|
{"id": 277, "subject": "M10-T35d: Adopt OffsetPager+DateTimeRangeFilter into NotificationReport (+backdrop token)", "status": "completed", "blockedBy": [274, 276, 273]},
|
||||||
{"id": 278, "subject": "M10-T35e: Adopt OffsetPager+DateTimeRangeFilter into ConfigurationAuditLog", "status": "pending", "blockedBy": [274, 276]},
|
{"id": 278, "subject": "M10-T35e: Adopt OffsetPager+DateTimeRangeFilter into ConfigurationAuditLog", "status": "completed", "blockedBy": [274, 276]},
|
||||||
{"id": 279, "subject": "M10-T35f: Adopt KeysetPager+DateTimeRangeFilter into SiteCallsReport (+backdrop token)", "status": "pending", "blockedBy": [275, 276, 273]},
|
{"id": 279, "subject": "M10-T35f: Adopt KeysetPager+DateTimeRangeFilter into SiteCallsReport (+backdrop token)", "status": "completed", "blockedBy": [275, 276, 273]},
|
||||||
{"id": 280, "subject": "M10-T35g: Adopt KeysetPager into AuditResultsGrid + DateTimeRangeFilter into AuditFilterBar", "status": "pending", "blockedBy": [275, 276]},
|
{"id": 280, "subject": "M10-T35g: Adopt KeysetPager into AuditResultsGrid + DateTimeRangeFilter into AuditFilterBar", "status": "completed", "blockedBy": [275, 276]},
|
||||||
{"id": 281, "subject": "M10-T35h: Adopt DateTimeRangeFilter into EventLogs", "status": "pending", "blockedBy": [276]},
|
{"id": 281, "subject": "M10-T35h: Adopt DateTimeRangeFilter into EventLogs", "status": "completed", "blockedBy": [276]},
|
||||||
{"id": 282, "subject": "M10-T34c: Tokenize remaining standalone backdrops + bg-* audit", "status": "pending", "blockedBy": [273]},
|
{"id": 282, "subject": "M10-T34c: Tokenize remaining standalone backdrops + bg-* audit", "status": "completed", "blockedBy": [273]},
|
||||||
{"id": 283, "subject": "M10-T34b: Dark-mode toggle + persistence + SSR hydration", "status": "pending", "blockedBy": [270, 273]},
|
{"id": 283, "subject": "M10-T34b: Dark-mode toggle + persistence + SSR hydration", "status": "completed", "blockedBy": [270, 273]},
|
||||||
{"id": 284, "subject": "M10-T36a: Accessibility pass — TreeView chevron + icon audit + toast verify", "status": "pending"},
|
{"id": 284, "subject": "M10-T36a: Accessibility pass — TreeView chevron + icon audit + toast verify", "status": "completed"},
|
||||||
{"id": 285, "subject": "M10-INT: Integration — build, docker, Playwright, a11y+dark smoke, docs, review", "status": "pending", "blockedBy": [271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284]}
|
{"id": 285, "subject": "M10-INT: Integration — build, docker, Playwright, a11y+dark smoke, docs, review", "status": "completed", "blockedBy": [271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-06-18"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,28 +4,28 @@
|
|||||||
"branch": "worktree-m7-opcua-mxgateway-ux",
|
"branch": "worktree-m7-opcua-mxgateway-ux",
|
||||||
"baseSha": "254e0e7",
|
"baseSha": "254e0e7",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 184, "subject": "M7-A1: Extract AlarmStateBadges shared component", "classification": "standard", "status": "pending"},
|
{"id": 184, "subject": "M7-A1: Extract AlarmStateBadges shared component", "classification": "standard", "status": "completed"},
|
||||||
{"id": 185, "subject": "M7-A2: Operator Alarm Summary page + fan-out service", "classification": "standard", "status": "pending", "blockedBy": [184]},
|
{"id": 185, "subject": "M7-A2: Operator Alarm Summary page + fan-out service", "classification": "standard", "status": "completed", "blockedBy": [184]},
|
||||||
{"id": 186, "subject": "M7-A3: Operator + Verifier roles + policies + LDAP mapping", "classification": "high-risk", "status": "pending"},
|
{"id": 186, "subject": "M7-A3: Operator + Verifier roles + policies + LDAP mapping", "classification": "high-risk", "status": "completed"},
|
||||||
{"id": 187, "subject": "M7-B1: Browse type-info fields on BrowseNode", "classification": "standard", "status": "pending"},
|
{"id": 187, "subject": "M7-B1: Browse type-info fields on BrowseNode", "classification": "standard", "status": "completed"},
|
||||||
{"id": 188, "subject": "M7-B2: BrowseNext continuation through browse contract", "classification": "high-risk", "status": "pending", "blockedBy": [187]},
|
{"id": 188, "subject": "M7-B2: BrowseNext continuation through browse contract", "classification": "high-risk", "status": "completed", "blockedBy": [187]},
|
||||||
{"id": 189, "subject": "M7-B3: Thread continuation token through browse plumbing", "classification": "standard", "status": "pending", "blockedBy": [188]},
|
{"id": 189, "subject": "M7-B3: Thread continuation token through browse plumbing", "classification": "standard", "status": "completed", "blockedBy": [188]},
|
||||||
{"id": 190, "subject": "M7-B4: Bounded recursive address-space search — adapter", "classification": "high-risk", "status": "pending", "blockedBy": [188]},
|
{"id": 190, "subject": "M7-B4: Bounded recursive address-space search — adapter", "classification": "high-risk", "status": "completed", "blockedBy": [188]},
|
||||||
{"id": 191, "subject": "M7-B5: Search plumbing — message + actor + comm + service", "classification": "standard", "status": "pending", "blockedBy": [189, 190]},
|
{"id": 191, "subject": "M7-B5: Search plumbing — message + actor + comm + service", "classification": "standard", "status": "completed", "blockedBy": [189, 190]},
|
||||||
{"id": 192, "subject": "M7-B6: NodeBrowserDialog — load-more + search box + type column", "classification": "standard", "status": "pending", "blockedBy": [189, 191]},
|
{"id": 192, "subject": "M7-B6: NodeBrowserDialog — load-more + search box + type column", "classification": "standard", "status": "completed", "blockedBy": [189, 191]},
|
||||||
{"id": 193, "subject": "M7-B7: Verify-endpoint — message + site probe handler", "classification": "high-risk", "status": "pending", "blockedBy": [191]},
|
{"id": 193, "subject": "M7-B7: Verify-endpoint — message + site probe handler", "classification": "high-risk", "status": "completed", "blockedBy": [191]},
|
||||||
{"id": 194, "subject": "M7-B8: Verify-endpoint plumbing + UI", "classification": "standard", "status": "pending", "blockedBy": [193]},
|
{"id": 194, "subject": "M7-B8: Verify-endpoint plumbing + UI", "classification": "standard", "status": "completed", "blockedBy": [193]},
|
||||||
{"id": 195, "subject": "M7-B9: Cert trust — per-node CertStore actor + broadcast", "classification": "high-risk", "status": "pending", "blockedBy": [193]},
|
{"id": 195, "subject": "M7-B9: Cert trust — per-node CertStore actor + broadcast", "classification": "high-risk", "status": "completed", "blockedBy": [193]},
|
||||||
{"id": 196, "subject": "M7-B10: Cert trust plumbing + cert-management UI", "classification": "standard", "status": "pending", "blockedBy": [194, 195]},
|
{"id": 196, "subject": "M7-B10: Cert trust plumbing + cert-management UI", "classification": "standard", "status": "completed", "blockedBy": [194, 195]},
|
||||||
{"id": 197, "subject": "M7-C1: PendingSecuredWrite entity + persistence + migration", "classification": "high-risk", "status": "pending"},
|
{"id": 197, "subject": "M7-C1: PendingSecuredWrite entity + persistence + migration", "classification": "high-risk", "status": "completed"},
|
||||||
{"id": 198, "subject": "M7-C2: Secured-write commands + submit/reject/list handlers", "classification": "high-risk", "status": "pending", "blockedBy": [197, 186]},
|
{"id": 198, "subject": "M7-C2: Secured-write commands + submit/reject/list handlers", "classification": "high-risk", "status": "completed", "blockedBy": [197, 186]},
|
||||||
{"id": 199, "subject": "M7-C3: Approve → site write relay", "classification": "high-risk", "status": "pending", "blockedBy": [198]},
|
{"id": 199, "subject": "M7-C3: Approve → site write relay", "classification": "high-risk", "status": "completed", "blockedBy": [198]},
|
||||||
{"id": 200, "subject": "M7-C4: AuditKind.SecuredWrite + audit wiring", "classification": "high-risk", "status": "pending", "blockedBy": [198, 199]},
|
{"id": 200, "subject": "M7-C4: AuditKind.SecuredWrite + audit wiring", "classification": "high-risk", "status": "completed", "blockedBy": [198, 199]},
|
||||||
{"id": 201, "subject": "M7-C5: Secured Writes Central UI page", "classification": "standard", "status": "pending", "blockedBy": [198, 199, 186]},
|
{"id": 201, "subject": "M7-C5: Secured Writes Central UI page", "classification": "standard", "status": "completed", "blockedBy": [198, 199, 186]},
|
||||||
{"id": 202, "subject": "M7-D1: OverrideCsvParser pure helper", "classification": "standard", "status": "pending"},
|
{"id": 202, "subject": "M7-D1: OverrideCsvParser pure helper", "classification": "standard", "status": "completed"},
|
||||||
{"id": 203, "subject": "M7-D2: InstanceConfigure CSV import UI", "classification": "standard", "status": "pending", "blockedBy": [202]},
|
{"id": 203, "subject": "M7-D2: InstanceConfigure CSV import UI", "classification": "standard", "status": "completed", "blockedBy": [202]},
|
||||||
{"id": 204, "subject": "M7-D3: CLI instance import-overrides --file", "classification": "small", "status": "pending", "blockedBy": [202]},
|
{"id": 204, "subject": "M7-D3: CLI instance import-overrides --file", "classification": "small", "status": "completed", "blockedBy": [202]},
|
||||||
{"id": 205, "subject": "M7-E1: Integration — docs, full build, docker rebuild, Playwright, smoke", "classification": "high-risk", "status": "pending", "blockedBy": [184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204]}
|
{"id": 205, "subject": "M7-E1: Integration — docs, full build, docker rebuild, Playwright, smoke", "classification": "high-risk", "status": "completed", "blockedBy": [184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204]}
|
||||||
],
|
],
|
||||||
"waves": {
|
"waves": {
|
||||||
"A": [184, 185, 186],
|
"A": [184, 185, 186],
|
||||||
@@ -34,5 +34,6 @@
|
|||||||
"D": [202, 203, 204],
|
"D": [202, 203, 204],
|
||||||
"E": [205]
|
"E": [205]
|
||||||
},
|
},
|
||||||
"lastUpdated": "2026-06-18"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-06-18-m8-transport.md",
|
"planPath": "docs/plans/2026-06-18-m8-transport.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 210, "subject": "M8-A1: Commons name-map types + preview/selection/summary extensions", "status": "pending"},
|
{"id": 210, "subject": "M8-A1: Commons name-map types + preview/selection/summary extensions", "status": "completed"},
|
||||||
{"id": 211, "subject": "M8-A2: Transport bundle DTOs — Site/DataConnection/Instance + BundleContentDto", "status": "pending"},
|
{"id": 211, "subject": "M8-A2: Transport bundle DTOs — Site/DataConnection/Instance + BundleContentDto", "status": "completed"},
|
||||||
{"id": 212, "subject": "M8-A3: LineDiffer — pure Myers per-line diff helper (T20)", "status": "pending"},
|
{"id": 212, "subject": "M8-A3: LineDiffer — pure Myers per-line diff helper (T20)", "status": "completed"},
|
||||||
{"id": 213, "subject": "M8-B1: DependencyResolver site/instance expansion", "status": "pending", "blockedBy": [211]},
|
{"id": 213, "subject": "M8-B1: DependencyResolver site/instance expansion", "status": "completed", "blockedBy": [211]},
|
||||||
{"id": 214, "subject": "M8-B2: EntitySerializer site/connection/instance mapping (both directions)", "status": "pending", "blockedBy": [211]},
|
{"id": 214, "subject": "M8-B2: EntitySerializer site/connection/instance mapping (both directions)", "status": "completed", "blockedBy": [211]},
|
||||||
{"id": 215, "subject": "M8-B3: ManifestBuilder + summary counts + schemaVersion 1.1", "status": "pending", "blockedBy": [210]},
|
{"id": 215, "subject": "M8-B3: ManifestBuilder + summary counts + schemaVersion 1.1", "status": "completed", "blockedBy": [210]},
|
||||||
{"id": 216, "subject": "M8-B4: Export plumbing — selection wiring, command, ManagementActor, CLI", "status": "pending", "blockedBy": [213, 214, 215]},
|
{"id": 216, "subject": "M8-B4: Export plumbing — selection wiring, command, ManagementActor, CLI", "status": "completed", "blockedBy": [213, 214, 215]},
|
||||||
{"id": 217, "subject": "M8-C1: ArtifactDiff — Myers code diff (T20) + Site/Connection/Instance compares", "status": "pending", "blockedBy": [211, 212]},
|
{"id": 217, "subject": "M8-C1: ArtifactDiff — Myers code diff (T20) + Site/Connection/Instance compares", "status": "completed", "blockedBy": [211, 212]},
|
||||||
{"id": 218, "subject": "M8-C2: BundleImporter.PreviewAsync — new-type diff + required-mapping detection + blockers", "status": "pending", "blockedBy": [214, 217]},
|
{"id": 218, "subject": "M8-C2: BundleImporter.PreviewAsync — new-type diff + required-mapping detection + blockers", "status": "completed", "blockedBy": [214, 217]},
|
||||||
{"id": 219, "subject": "M8-D1: BundleImporter.ApplyAsync — nameMap, resolve-or-create, instance upsert + FK rewire", "status": "pending", "blockedBy": [218]},
|
{"id": 219, "subject": "M8-D1: BundleImporter.ApplyAsync — nameMap, resolve-or-create, instance upsert + FK rewire", "status": "completed", "blockedBy": [218]},
|
||||||
{"id": 220, "subject": "M8-D2: #16 — real stale-instance enumeration in ImportResult", "status": "pending", "blockedBy": [219]},
|
{"id": 220, "subject": "M8-D2: #16 — real stale-instance enumeration in ImportResult", "status": "completed", "blockedBy": [219]},
|
||||||
{"id": 221, "subject": "M8-D3: Import plumbing — name-map through command, ManagementActor, CLI", "status": "pending", "blockedBy": [219, 216]},
|
{"id": 221, "subject": "M8-D3: Import plumbing — name-map through command, ManagementActor, CLI", "status": "completed", "blockedBy": [219, 216]},
|
||||||
{"id": 222, "subject": "M8-E1: Export wizard — Sites/Instances selection", "status": "pending", "blockedBy": [216]},
|
{"id": 222, "subject": "M8-E1: Export wizard — Sites/Instances selection", "status": "completed", "blockedBy": [216]},
|
||||||
{"id": 223, "subject": "M8-E2: Import wizard — Map step + Modified +/- diff render", "status": "pending", "blockedBy": [221]},
|
{"id": 223, "subject": "M8-E2: Import wizard — Map step + Modified +/- diff render", "status": "completed", "blockedBy": [221]},
|
||||||
{"id": 224, "subject": "M8-INT: Docs, full build, docker rebuild, Playwright, live smoke, end-to-end trace", "status": "pending", "blockedBy": [222, 223]}
|
{"id": 224, "subject": "M8-INT: Docs, full build, docker rebuild, Playwright, live smoke, end-to-end trace", "status": "completed", "blockedBy": [222, 223]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-06-18"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,23 @@
|
|||||||
"branch": "worktree-m9-templates-authoring",
|
"branch": "worktree-m9-templates-authoring",
|
||||||
"baseRef": "72aec3b4",
|
"baseRef": "72aec3b4",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 240, "label": "T22", "subject": "M9-T22: Template tree search box", "classification": "small", "wave": 1, "status": "pending"},
|
{"id": 240, "label": "T22", "subject": "M9-T22: Template tree search box", "classification": "small", "wave": 1, "status": "completed"},
|
||||||
{"id": 241, "label": "CLI", "subject": "M9-CLI: cached-call retry/discard CLI group", "classification": "small", "wave": 1, "status": "pending"},
|
{"id": 241, "label": "CLI", "subject": "M9-CLI: cached-call retry/discard CLI group", "classification": "small", "wave": 1, "status": "completed"},
|
||||||
{"id": 242, "label": "T28a", "subject": "M9-T28a: Strict expression-trigger kind — backend", "classification": "small", "wave": 1, "status": "pending"},
|
{"id": 242, "label": "T28a", "subject": "M9-T28a: Strict expression-trigger kind — backend", "classification": "small", "wave": 1, "status": "completed"},
|
||||||
{"id": 243, "label": "T28b", "subject": "M9-T28b: Strict trigger-kind — UI selector + CLI flag", "classification": "small", "wave": 1, "status": "pending", "blockedBy": [242]},
|
{"id": 243, "label": "T28b", "subject": "M9-T28b: Strict trigger-kind — UI selector + CLI flag", "classification": "small", "wave": 1, "status": "completed", "blockedBy": [242]},
|
||||||
{"id": 244, "label": "T23a", "subject": "M9-T23a: Folder sibling reorder — service + command + handler", "classification": "standard", "wave": 2, "status": "pending"},
|
{"id": 244, "label": "T23a", "subject": "M9-T23a: Folder sibling reorder — service + command + handler", "classification": "standard", "wave": 2, "status": "completed"},
|
||||||
{"id": 245, "label": "T23b", "subject": "M9-T23b: Folder reorder + root context menu — UI", "classification": "standard", "wave": 2, "status": "pending", "blockedBy": [240, 244]},
|
{"id": 245, "label": "T23b", "subject": "M9-T23b: Folder reorder + root context menu — UI", "classification": "standard", "wave": 2, "status": "completed", "blockedBy": [240, 244]},
|
||||||
{"id": 246, "label": "T25", "subject": "M9-T25: Connection live-status indicators", "classification": "standard", "wave": 2, "status": "pending"},
|
{"id": 246, "label": "T25", "subject": "M9-T25: Connection live-status indicators", "classification": "standard", "wave": 2, "status": "completed"},
|
||||||
{"id": 247, "label": "T24a", "subject": "M9-T24a: Move data connection between sites — command + handler + guards", "classification": "high-risk", "wave": 3, "status": "pending"},
|
{"id": 247, "label": "T24a", "subject": "M9-T24a: Move data connection between sites — command + handler + guards", "classification": "high-risk", "wave": 3, "status": "completed"},
|
||||||
{"id": 248, "label": "T24b", "subject": "M9-T24b: Move connection — UI dialog + action", "classification": "standard", "wave": 3, "status": "pending", "blockedBy": [247, 246]},
|
{"id": 248, "label": "T24b", "subject": "M9-T24b: Move connection — UI dialog + action", "classification": "standard", "wave": 3, "status": "completed", "blockedBy": [247, 246]},
|
||||||
{"id": 249, "label": "T32a", "subject": "M9-T32a: SharedSchema entity + EF config + migration + repo", "classification": "high-risk", "wave": 3, "status": "pending"},
|
{"id": 249, "label": "T32a", "subject": "M9-T32a: SharedSchema entity + EF config + migration + repo", "classification": "high-risk", "wave": 3, "status": "completed"},
|
||||||
{"id": 250, "label": "T32b", "subject": "M9-T32b: JSON Schema $ref resolver + deploy-time validation", "classification": "high-risk", "wave": 3, "status": "pending", "blockedBy": [249]},
|
{"id": 250, "label": "T32b", "subject": "M9-T32b: JSON Schema $ref resolver + deploy-time validation", "classification": "high-risk", "wave": 3, "status": "completed", "blockedBy": [249]},
|
||||||
{"id": 251, "label": "T32c", "subject": "M9-T32c: Schema library — CRUD commands + handlers + Central UI page", "classification": "high-risk", "wave": 4, "status": "pending", "blockedBy": [249, 250]},
|
{"id": 251, "label": "T32c", "subject": "M9-T32c: Schema library — CRUD commands + handlers + Central UI page", "classification": "high-risk", "wave": 4, "status": "completed", "blockedBy": [249, 250]},
|
||||||
{"id": 252, "label": "T30", "subject": "M9-T30: Schema-driven nested value-entry forms", "classification": "standard", "wave": 4, "status": "pending", "blockedBy": [250]},
|
{"id": 252, "label": "T30", "subject": "M9-T30: Schema-driven nested value-entry forms", "classification": "standard", "wave": 4, "status": "completed", "blockedBy": [250]},
|
||||||
{"id": 253, "label": "T31", "subject": "M9-T31: Monaco JSON-Schema hover/completion", "classification": "standard", "wave": 4, "status": "pending", "blockedBy": [250, 252]},
|
{"id": 253, "label": "T31", "subject": "M9-T31: Monaco JSON-Schema hover/completion", "classification": "standard", "wave": 4, "status": "completed", "blockedBy": [250, 252]},
|
||||||
{"id": 254, "label": "T26a", "subject": "M9-T26a: Inheritance resolve service + query command", "classification": "high-risk", "wave": 4, "status": "pending"},
|
{"id": 254, "label": "T26a", "subject": "M9-T26a: Inheritance resolve service + query command", "classification": "high-risk", "wave": 4, "status": "completed"},
|
||||||
{"id": 255, "label": "T26b", "subject": "M9-T26b: TemplateEdit — full inherited set + staleness banner", "classification": "standard", "wave": 4, "status": "pending", "blockedBy": [254]},
|
{"id": 255, "label": "T26b", "subject": "M9-T26b: TemplateEdit — full inherited set + staleness banner", "classification": "standard", "wave": 4, "status": "completed", "blockedBy": [254]},
|
||||||
{"id": 256, "label": "INT", "subject": "M9-INT: Integration — build, docker, Playwright, smoke, end-to-end trace", "classification": "high-risk", "wave": 5, "status": "pending", "blockedBy": [240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]}
|
{"id": 256, "label": "INT", "subject": "M9-INT: Integration — build, docker, Playwright, smoke, end-to-end trace", "classification": "high-risk", "wave": 5, "status": "completed", "blockedBy": [240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]}
|
||||||
],
|
],
|
||||||
"serializationPoints": {
|
"serializationPoints": {
|
||||||
"ManagementActor.cs+ManagementCommandRegistry.cs": [244, 247, 251, 254],
|
"ManagementActor.cs+ManagementCommandRegistry.cs": [244, 247, 251, 254],
|
||||||
@@ -30,5 +30,6 @@
|
|||||||
"TemplateEdit.razor": [243, 255],
|
"TemplateEdit.razor": [243, 255],
|
||||||
"ParameterValueForm.razor/value-entry-surface": [252, 253]
|
"ParameterValueForm.razor/value-entry-surface": [252, 253]
|
||||||
},
|
},
|
||||||
"lastUpdated": "2026-06-18"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-06-19-sms-notifications.md",
|
"planPath": "docs/plans/2026-06-19-sms-notifications.md",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{"id": 299, "subject": "S1: Commons foundation — NotificationType.Sms + recipient phone + SmsConfiguration + repo iface", "status": "pending"},
|
{"id": 299, "subject": "S1: Commons foundation — NotificationType.Sms + recipient phone + SmsConfiguration + repo iface", "status": "completed"},
|
||||||
{"id": 300, "subject": "S2: Config-DB — EF mappings + AuthToken encryption + idempotent migration", "status": "pending", "blockedBy": [299]},
|
{"id": 300, "subject": "S2: Config-DB — EF mappings + AuthToken encryption + idempotent migration", "status": "completed", "blockedBy": [299]},
|
||||||
{"id": 303, "subject": "S5: Management — list-command Type + SMS-config commands/handlers", "status": "pending", "blockedBy": [299]},
|
{"id": 303, "subject": "S5: Management — list-command Type + SMS-config commands/handlers", "status": "completed", "blockedBy": [299]},
|
||||||
{"id": 301, "subject": "S3: SmsNotificationDeliveryAdapter (Twilio REST) + classifier + options + DI + tests", "status": "pending", "blockedBy": [299, 300]},
|
{"id": 301, "subject": "S3: SmsNotificationDeliveryAdapter (Twilio REST) + classifier + options + DI + tests", "status": "completed", "blockedBy": [299, 300]},
|
||||||
{"id": 302, "subject": "S4: NotificationOutboxActor ingest type-stamping from list", "status": "pending", "blockedBy": [299]},
|
{"id": 302, "subject": "S4: NotificationOutboxActor ingest type-stamping from list", "status": "completed", "blockedBy": [299]},
|
||||||
{"id": 304, "subject": "S6: CLI — list --type/--phones + notification sms group", "status": "pending", "blockedBy": [303]},
|
{"id": 304, "subject": "S6: CLI — list --type/--phones + notification sms group", "status": "completed", "blockedBy": [303]},
|
||||||
{"id": 305, "subject": "S7: Central UI — NotificationListForm adapter-gated Type selector + per-type recipient input", "status": "pending", "blockedBy": [299, 303]},
|
{"id": 305, "subject": "S7: Central UI — NotificationListForm adapter-gated Type selector + per-type recipient input", "status": "completed", "blockedBy": [299, 303]},
|
||||||
{"id": 306, "subject": "S8: Central UI — NotificationLists Type column", "status": "pending", "blockedBy": [299]},
|
{"id": 306, "subject": "S8: Central UI — NotificationLists Type column", "status": "completed", "blockedBy": [299]},
|
||||||
{"id": 307, "subject": "S9: Central UI — SMS configuration page (/notifications/sms)", "status": "pending", "blockedBy": [300, 303]},
|
{"id": 307, "subject": "S9: Central UI — SMS configuration page (/notifications/sms)", "status": "completed", "blockedBy": [300, 303]},
|
||||||
{"id": 308, "subject": "S10: Transport — recipient PhoneNumber DTO + SmsConfigDto round-trip", "status": "pending", "blockedBy": [299]},
|
{"id": 308, "subject": "S10: Transport — recipient PhoneNumber DTO + SmsConfigDto round-trip", "status": "completed", "blockedBy": [299]},
|
||||||
{"id": 309, "subject": "S11: INT — build, drift, docker, Playwright, live smoke, docs, whole-branch review", "status": "pending", "blockedBy": [300, 301, 302, 303, 304, 305, 306, 307, 308]}
|
{"id": 309, "subject": "S11: INT — build, drift, docker, Playwright, live smoke, docs, whole-branch review", "status": "completed", "blockedBy": [300, 301, 302, 303, 304, 305, 306, 307, 308]}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-06-19"
|
"lastUpdated": "2026-08-01",
|
||||||
|
"status": "2026-08-01 bookkeeping sync: statuses reconciled against merged code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
**Date:** 2026-06-26 · **Status:** APPROVED (design) · **Area:** Deployment Manager / Site Runtime / Cluster Communication
|
**Date:** 2026-06-26 · **Status:** APPROVED (design) · **Area:** Deployment Manager / Site Runtime / Cluster Communication
|
||||||
**Fixes:** [`docs/known-issues/2026-06-26-deploy-config-exceeds-akka-frame-size.md`](../known-issues/2026-06-26-deploy-config-exceeds-akka-frame-size.md)
|
**Fixes:** [`docs/known-issues/2026-06-26-deploy-config-exceeds-akka-frame-size.md`](../known-issues/2026-06-26-deploy-config-exceeds-akka-frame-size.md)
|
||||||
|
|
||||||
|
> **2026-08-01 bookkeeping sync:** the central→site ClusterClient hop described below is now **gRPC** (`SiteCommandService`/`GrpcSiteTransport`) after the 2026-07-22 ClusterClient→gRPC migration — the notify-and-fetch behavior described here is unchanged.
|
||||||
|
|
||||||
## 1. Problem
|
## 1. Problem
|
||||||
|
|
||||||
The flattened instance config travels over Akka on **two** hops today, and both are bounded by Akka.Remote's default `maximum-frame-size` (**128 KB / `128000b`**). A large config (e.g. a 3rd composition of the same base template) silently breaks both:
|
The flattened instance config travels over Akka on **two** hops today, and both are bounded by Akka.Remote's default `maximum-frame-size` (**128 KB / `128000b`**). A large config (e.g. a 3rd composition of the same base template) silently breaks both:
|
||||||
@@ -177,7 +179,7 @@ Smoke-tested on the docker cluster (rebuilt from this branch). Validated end-to-
|
|||||||
|
|
||||||
The smoke surfaced two real bugs in the reconciliation path (missed by unit/integration tests because those didn't have a second concurrent node or a lingering expired row), both fixed:
|
The smoke surfaced two real bugs in the reconciliation path (missed by unit/integration tests because those didn't have a second concurrent node or a lingering expired row), both fixed:
|
||||||
1. **Concurrent-gap omit** — when two nodes were concurrently missing the same instance, the second node's `StagePendingIfAbsentAsync` returned false and the handler *omitted* the item, leaving that node unhealed. Fix: on false, return the **existing** pending row's deploymentId + token (multi-use within TTL) so all concurrently-missing nodes heal in the same round.
|
1. **Concurrent-gap omit** — when two nodes were concurrently missing the same instance, the second node's `StagePendingIfAbsentAsync` returned false and the handler *omitted* the item, leaving that node unhealed. Fix: on false, return the **existing** pending row's deploymentId + token (multi-use within TTL) so all concurrently-missing nodes heal in the same round.
|
||||||
2. **Expired pending row blocks self-heal** — `StagePendingIfAbsentAsync` checked existence by `InstanceId` ignoring expiry, so an expired-but-unpurged row (the periodic purge is still a deferred TODO) blocked a fresh stage *and* would collide with the snapshot's reused `DeploymentId` on the unique index. Fix: **expiry-aware staging** — delete expired rows for the instance first, then check only live rows; `GetPendingDeploymentByInstanceIdAsync` filters by expiry. This also opportunistically cleans expired rows, reducing reliance on the deferred periodic purge.
|
2. **Expired pending row blocks self-heal** — `StagePendingIfAbsentAsync` checked existence by `InstanceId` ignoring expiry, so an expired-but-unpurged row (the periodic purge was a deferred TODO at the time — **RESOLVED 2026-08-01 bookkeeping sync: `PendingDeploymentPurgeActor` shipped in PLAN-04; deferred-work register row #20 closed**) blocked a fresh stage *and* would collide with the snapshot's reused `DeploymentId` on the unique index. Fix: **expiry-aware staging** — delete expired rows for the instance first, then check only live rows; `GetPendingDeploymentByInstanceIdAsync` filters by expiry. This also opportunistically cleans expired rows, reducing reliance on the deferred periodic purge.
|
||||||
|
|
||||||
## 12. Affected files (for the plan)
|
## 12. Affected files (for the plan)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# MES Alarm-Status API — Implementation Plan
|
# MES Alarm-Status API — Implementation Plan
|
||||||
|
|
||||||
**Date:** 2026-06-30
|
**Date:** 2026-06-30
|
||||||
**Status:** Draft plan — NOT yet executed. Captures design + task breakdown for review.
|
**Status:** **Phase 1 [repo] COMPLETE 2026-08-01** — the `Alarms.CurrentAsync()` script accessor and the `AckTime` native-mirror enrichment are shipped (see §7). Phases 2–4 are **deployed config** (inbound methods + CvdReactor template scripts) and still pending; they need a live rig. All §6 open questions DECIDED 2026-08-01 (design review with user).
|
||||||
**Component touchpoints:** Inbound API (#14), Script Analysis (#25), Site Runtime (#3), Template Engine (#1) — plus deployed config (inbound methods + CvdReactor/MESReceiver template scripts).
|
**Component touchpoints:** Inbound API (#14), Script Analysis (#25), Site Runtime (#3), Template Engine (#1) — plus deployed config (inbound methods + CvdReactor template scripts).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -12,9 +12,9 @@ Port the legacy **WWSupport / APIServer** MES alarm-status endpoints onto ScadaB
|
|||||||
|
|
||||||
1. **Update** the existing `SimpleAlarmStatusRequest` inbound method (currently a stub) to do real work.
|
1. **Update** the existing `SimpleAlarmStatusRequest` inbound method (currently a stub) to do real work.
|
||||||
2. **Create** a new `AlarmStatus` inbound method (the full, filtered endpoint).
|
2. **Create** a new `AlarmStatus` inbound method (the full, filtered endpoint).
|
||||||
3. Both inbound methods **forward to MES-receiver site scripts** (`SAPID → BTDB machine lookup → Route.To(code).Call(...)`), exactly like `MesMoveIn`.
|
3. Both inbound methods **forward to site scripts** (`SAPID → BTDB machine lookup → Route.To(code).Call(...)`), exactly like `MesMoveIn`.
|
||||||
4. The **generic version on the `MESReceiver` template** resolves the machine's alarms by **querying the BTDB `MachineAlarm` table** (the legacy approach).
|
4. ~~The generic version on the `MESReceiver` template queries the BTDB `MachineAlarm` table.~~ **DROPPED (Q2, 2026-08-01):** no MESReceiver version ships — a config-only answer with no live state was judged not worth having. Machines whose template doesn't implement the scripts get a clean `WasSuccessful=false` "not supported on this machine" error from the inbound router.
|
||||||
5. The **`CvdReactor` template provides an override version** that reads the **alarms actually defined on the CvdReactor object** (its 7 native alarm-source bindings) directly — no `MachineAlarm` lookup.
|
5. The **`CvdReactor` template provides the (only) implementation**, reading the **alarms actually defined on the CvdReactor object** (its 7 native alarm-source bindings) directly — no `MachineAlarm` lookup anywhere.
|
||||||
|
|
||||||
Legacy spec being mirrored: [`docs/former-api-specs/mes/Alarm-API.md`](../former-api-specs/mes/Alarm-API.md).
|
Legacy spec being mirrored: [`docs/former-api-specs/mes/Alarm-API.md`](../former-api-specs/mes/Alarm-API.md).
|
||||||
|
|
||||||
@@ -74,10 +74,11 @@ From the site-script API map:
|
|||||||
```
|
```
|
||||||
INBOUND (central) ENABLING CODE (repo) SITE TEMPLATE SCRIPTS (deployed config)
|
INBOUND (central) ENABLING CODE (repo) SITE TEMPLATE SCRIPTS (deployed config)
|
||||||
───────────────── ──────────────────── ───────────────────────────────────────
|
───────────────── ──────────────────── ───────────────────────────────────────
|
||||||
SimpleAlarmStatusRequest ─┐ Alarms script accessor ┌────► MESReceiver.SimpleAlarmStatus (BTDB MachineAlarm)
|
SimpleAlarmStatusRequest ─┐ Alarms script accessor
|
||||||
├─ Route.To(code).Call(...) ──┤ MESReceiver.AlarmStatus (BTDB MachineAlarm)
|
├─ Route.To(code).Call(...) ─────────► CvdReactor.SimpleAlarmStatus (native sources)
|
||||||
AlarmStatus (new) ─┘ (ScriptRuntimeContext + └────► CvdReactor.SimpleAlarmStatus (native sources — OVERRIDE)
|
AlarmStatus (new) ─┘ + AckTime mirror enrichment ──► CvdReactor.AlarmStatus (native sources)
|
||||||
ScriptCompileSurface) CvdReactor.AlarmStatus (native sources — OVERRIDE)
|
(ScriptRuntimeContext +
|
||||||
|
ScriptCompileSurface) (no MESReceiver version — Q2 DECIDED: dropped)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.1 Layer A — Inbound API methods (deployed config; central)
|
### 5.1 Layer A — Inbound API methods (deployed config; central)
|
||||||
@@ -110,6 +111,8 @@ try {
|
|||||||
|
|
||||||
> **Note:** Machine resolution stays on the inbound side (central) because it is environment/SQL-shaped and identical for both endpoints; site scripts receive an already-resolved `MachineCode` plus the alarm filter.
|
> **Note:** Machine resolution stays on the inbound side (central) because it is environment/SQL-shaped and identical for both endpoints; site scripts receive an already-resolved `MachineCode` plus the alarm filter.
|
||||||
|
|
||||||
|
> **Unsupported machines (Q2 DECIDED):** since only CvdReactor implements the site scripts, `Route.Call` on any other machine fails script-not-found. Both routers catch that case specifically and return `{ WasSuccessful=false, ErrorText = $"Alarm status is not supported on machine '{code}'" }` instead of the raw exception text (verify during impl what exception/message `Route.Call` yields for a missing script so the catch can distinguish it).
|
||||||
|
|
||||||
### 5.2 Layer B — Enabling code change: script-facing `Alarms` accessor (repo)
|
### 5.2 Layer B — Enabling code change: script-facing `Alarms` accessor (repo)
|
||||||
|
|
||||||
New read-only accessor on the site script surface so scripts can enumerate the instance's current alarms.
|
New read-only accessor on the site script surface so scripts can enumerate the instance's current alarms.
|
||||||
@@ -132,9 +135,14 @@ public sealed record ScriptAlarm(
|
|||||||
string Message, string AlarmTypeName, string Category,
|
string Message, string AlarmTypeName, string Category,
|
||||||
string OperatorUser, string OperatorComment,
|
string OperatorUser, string OperatorComment,
|
||||||
DateTimeOffset? OriginalRaiseTime, DateTimeOffset Timestamp,
|
DateTimeOffset? OriginalRaiseTime, DateTimeOffset Timestamp,
|
||||||
|
DateTimeOffset? AckTime, // Q4 DECIDED: real ack timestamp from the enriched mirror
|
||||||
string CurrentValue, string LimitValue, bool IsConfiguredPlaceholder);
|
string CurrentValue, string LimitValue, bool IsConfiguredPlaceholder);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Ack-timestamp enrichment (Q4 DECIDED 2026-08-01 — enrich the mirror now, not a follow-up):**
|
||||||
|
- Add an additive `AckTime` (`DateTimeOffset?`) to `AlarmStateChanged`, the vendored `AlarmStateUpdate` proto (additive field number, never reuse — manual toggle-build-copy-untoggle regen), and the site `native_alarm_state` persistence so it survives failover.
|
||||||
|
- Semantics: when the underlying source supplies a true ack time (OPC UA A&C ack transitions do), use it; when it doesn't (MxGateway events without one), stamp the observation time of the ack transition at the DCL — accurate to when the system saw the ack, never fabricated. Null while unacked; cleared on re-raise.
|
||||||
|
|
||||||
Implementation:
|
Implementation:
|
||||||
- Runtime: `Alarms.CurrentAsync()` Asks the Instance Actor for its alarm snapshot (reuse the existing internal alarm-state map that feeds `DebugViewSnapshot.AlarmStates`; project each `AlarmStateChanged` → `ScriptAlarm`). New internal request/response message (or reuse `DebugSnapshotRequest` and project off `AlarmStates`).
|
- Runtime: `Alarms.CurrentAsync()` Asks the Instance Actor for its alarm snapshot (reuse the existing internal alarm-state map that feeds `DebugViewSnapshot.AlarmStates`; project each `AlarmStateChanged` → `ScriptAlarm`). New internal request/response message (or reuse `DebugSnapshotRequest` and project off `AlarmStates`).
|
||||||
- Compile surface: matching stub returning `Task.FromResult(empty)` so design-time compile + Test Run pass.
|
- Compile surface: matching stub returning `Task.FromResult(empty)` so design-time compile + Test Run pass.
|
||||||
@@ -143,13 +151,9 @@ Implementation:
|
|||||||
|
|
||||||
### 5.3 Layer C — Site template scripts (deployed config)
|
### 5.3 Layer C — Site template scripts (deployed config)
|
||||||
|
|
||||||
**MESReceiver (generic / "as before" — queries BTDB `MachineAlarm`):** `SimpleAlarmStatus` and `AlarmStatus`.
|
**MESReceiver version — DROPPED (Q2 DECIDED 2026-08-01).** With no native alarm sources, MESReceiver has no live "triggered" signal; a configured-catalog-only answer was judged misleading rather than useful. No BTDB `MachineAlarm` read ships anywhere in this feature. Machines without the CvdReactor-style scripts get the router's "not supported on machine" error (§5.1).
|
||||||
- Use `await Database.Connection("BTDB")` (raw ADO.NET — site Database helper has **no** `QuerySingleAsync`; that's inbound-only) to read the machine's `MachineAlarm` rows (`Name`, `Severity`, `FlaggedForMES`, …) for the resolved `MachineCode`.
|
|
||||||
- Apply legacy filter semantics (Simple = flagged-only; full = `FlaggedOnly/MinSeverity/MaxSeverity/NameFilter`).
|
|
||||||
- Determine live "triggered" state + ack/timestamps and project to `AlarmInfo` (**live-state source = open question §6.2**).
|
|
||||||
- **No `_A`/`_B` side parsing.** This generic version is **whole-machine**, matching the legacy endpoint — it accepts a bare/numeric SAPID and never requires (or errors on) a missing side suffix. Side routing is **CvdReactor-only** (§5.3).
|
|
||||||
|
|
||||||
**CvdReactor (override — reads native sources directly, no DB):** `SimpleAlarmStatus` and `AlarmStatus` as **root-level scripts** (so `Route.To(code).Call("SimpleAlarmStatus")` resolves CvdReactor's version for CvdReactor instances — mirrors `MesMoveIn`).
|
**CvdReactor (the only implementation — reads native sources directly, no DB):** `SimpleAlarmStatus` and `AlarmStatus` (Q5 DECIDED: names match the endpoints, template-agnostic contract) as **root-level scripts** (mirrors `MesMoveIn`).
|
||||||
|
|
||||||
The override **routes left vs right off the SAPID suffix** — `_A` ⇒ Left, `_B` ⇒ Right — and scopes the returned alarms to that side's native sources plus the shared reactor-wide source. The `_LT` (leak-test) suffix is **ignored** for scoping: it is stripped before reading the side, and both the side's run and leak-test sources are included. Source → side map (the 7 `CvdReactor` native sources):
|
The override **routes left vs right off the SAPID suffix** — `_A` ⇒ Left, `_B` ⇒ Right — and scopes the returned alarms to that side's native sources plus the shared reactor-wide source. The `_LT` (leak-test) suffix is **ignored** for scoping: it is stripped before reading the side, and both the side's run and leak-test sources are included. Source → side map (the 7 `CvdReactor` native sources):
|
||||||
|
|
||||||
@@ -160,6 +164,10 @@ The override **routes left vs right off the SAPID suffix** — `_A` ⇒ Left, `_
|
|||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// CvdReactor.SimpleAlarmStatus (sketch)
|
// CvdReactor.SimpleAlarmStatus (sketch)
|
||||||
|
// Q1 DECIDED: MES relevance = dedicated severity band. Galaxy alarm priorities for
|
||||||
|
// MES-relevant alarms are configured into 900-999; nothing else may use that band.
|
||||||
|
const int MesBandMin = 900;
|
||||||
|
const int MesBandMax = 999;
|
||||||
try {
|
try {
|
||||||
var raw = (Parameters["SAPID"] as string) ?? "";
|
var raw = (Parameters["SAPID"] as string) ?? "";
|
||||||
var code = Parameters["MachineCode"]?.ToString() ?? "";
|
var code = Parameters["MachineCode"]?.ToString() ?? "";
|
||||||
@@ -180,16 +188,17 @@ try {
|
|||||||
var infos = alarms
|
var infos = alarms
|
||||||
.Where(a => a.Active && !a.IsConfiguredPlaceholder) // only triggered
|
.Where(a => a.Active && !a.IsConfiguredPlaceholder) // only triggered
|
||||||
.Where(a => InScope(a.NativeSourceCanonicalName)) // _A => Left*, _B => Right*, + ReactorAlarms
|
.Where(a => InScope(a.NativeSourceCanonicalName)) // _A => Left*, _B => Right*, + ReactorAlarms
|
||||||
// SimpleAlarmStatus: flagged-only + acked always included (see §6.1 for FlaggedForMES)
|
// SimpleAlarmStatus semantics: flagged-only (= MES band, Q1) + acked always included
|
||||||
|
.Where(a => a.Severity >= MesBandMin && a.Severity <= MesBandMax)
|
||||||
.Select(a => new {
|
.Select(a => new {
|
||||||
Name = a.Name,
|
Name = a.Name,
|
||||||
HierarchicalName = code + "." + a.Name,
|
HierarchicalName = code + "." + a.Name,
|
||||||
Description = a.Message, // §6.3
|
Description = string.IsNullOrEmpty(a.Message) ? a.AlarmTypeName : a.Message, // Q3 DECIDED
|
||||||
IsFlaggedForMES = true, // §6.1
|
IsFlaggedForMES = a.Severity >= MesBandMin && a.Severity <= MesBandMax, // Q1 DECIDED: real predicate
|
||||||
Severity = a.Severity,
|
Severity = a.Severity,
|
||||||
StatusCode = a.Acknowledged ? "Triggered.Acked" : "Triggered",
|
StatusCode = a.Acknowledged ? "Triggered.Acked" : "Triggered",
|
||||||
TriggeredDT = (a.OriginalRaiseTime ?? a.Timestamp),
|
TriggeredDT = (a.OriginalRaiseTime ?? a.Timestamp),
|
||||||
AckDT = (DateTime?)null, // §6.4
|
AckDT = a.AckTime, // Q4 DECIDED: enriched mirror
|
||||||
AckComment = a.OperatorComment,
|
AckComment = a.OperatorComment,
|
||||||
}).ToList();
|
}).ToList();
|
||||||
return new { WasSuccessful = true, ErrorText = (string)null, Alarms = infos };
|
return new { WasSuccessful = true, ErrorText = (string)null, Alarms = infos };
|
||||||
@@ -197,29 +206,27 @@ try {
|
|||||||
return new { WasSuccessful = false, ErrorText = "SimpleAlarmStatus failed: " + ex.Message, Alarms = Array.Empty<object>() };
|
return new { WasSuccessful = false, ErrorText = "SimpleAlarmStatus failed: " + ex.Message, Alarms = Array.Empty<object>() };
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
`CvdReactor.AlarmStatus` is the same — same side parsing + `InScope` filter — but additionally applies the passed `AlarmFilter` (NameFilter/MinSeverity/MaxSeverity/FlaggedOnly/IncludeAcked) over the scoped native list. (When `AlarmStatus` was selected by `Code`/`ZTag`/`MachineID` with no SAPID suffix, `side == null`; in that case return all sources instead of erroring — see §6.6.)
|
`CvdReactor.AlarmStatus` is the same — same side parsing + `InScope` filter, WITHOUT the always-on band filter — but applies the passed `AlarmFilter` (NameFilter/MinSeverity/MaxSeverity/FlaggedOnly/IncludeAcked) over the scoped native list; `FlaggedOnly=true` means the MES-band predicate (Q1). `IsFlaggedForMES` is always reported per-row from the band predicate. (When `AlarmStatus` was selected by `Code`/`ZTag`/`MachineID` with no SAPID suffix, `side == null`; in that case return all sources instead of erroring — Q6b DECIDED.)
|
||||||
|
|
||||||
> **Shared `ReactorAlarms` (`Z28061.`) is included on both sides** — reactor-wide faults apply regardless of side. Flip this if MES wants strictly side-local alarms (drop the `StartsWith("Reactor")` clause).
|
> **Shared `ReactorAlarms` (`Z28061.`) is included on both sides — Q6a DECIDED 2026-08-01:** reactor-wide faults apply regardless of which side MES asks about.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Key design decisions & OPEN QUESTIONS (resolve before execution)
|
## 6. Key design decisions — ALL DECIDED 2026-08-01 (design review with user)
|
||||||
|
|
||||||
These are the Socratic checkpoints — they change script bodies and/or the contract.
|
1. **`IsFlaggedForMES` for native alarms — DECIDED: severity band.** MES relevance is encoded in the alarm severity itself: MES-relevant alarms are configured (in the Galaxy alarm priority) into a **dedicated band 900–999** reserved exclusively for MES-relevant alarms, so it cannot collide with ordinary criticality tuning. Scripts carry `MesBandMin = 900` / `MesBandMax = 999` as named constants; `IsFlaggedForMES = (Severity in band)` is reported honestly per row; `SimpleAlarmStatus` (always flagged-only) and `AlarmStatus` with `FlaggedOnly=true` filter by the band predicate. Since `Severity` is returned verbatim, MES's own `MinSeverity`/`MaxSeverity` filters compose naturally with the band. No new tables, code flags, or allow-lists. **Operational prerequisite:** the Galaxy alarm priorities for MES-relevant CvdReactor alarms must be set into 900–999 before the endpoints are meaningful.
|
||||||
|
|
||||||
1. **`IsFlaggedForMES` for native alarms.** The native model has no MES flag (it lived in `MachineAlarm.FlaggedForMES`). Options: (a) treat all native alarms as MES-relevant (`true`); (b) reintroduce an MES allow-list (per native source binding, or a small config/table) the override consults; (c) for `SimpleAlarmStatus` (flagged-only) return all, and only honor `FlaggedOnly` in the full endpoint by cross-referencing `MachineAlarm`. **Recommend (a)** for v1 with (b) as a follow-up, unless MES needs a true flag.
|
2. **MESReceiver version — DECIDED: dropped entirely.** With no native alarm sources there is no live state; a config-only catalog answer was judged not worth shipping. Only CvdReactor implements the scripts; other machines get the router's clean "not supported on machine" error (§5.1). No BTDB `MachineAlarm` dependency remains.
|
||||||
|
|
||||||
2. **MESReceiver live-state source.** MESReceiver has no native alarm sources, so after reading `MachineAlarm` config it has no live "InAlarm" source. Options: (a) MESReceiver version returns **configured alarms only** (no live triggered filter) — explicitly a degraded/reference path; (b) MESReceiver version ALSO reads `Alarms.CurrentAsync()` and cross-references `MachineAlarm` by name (works only if the instance happens to mirror native alarms); (c) declare the BTDB-only path returns config and document that live status requires the CvdReactor-style override. **Recommend (c)** — the real live path is CvdReactor; the MESReceiver version demonstrates the MachineAlarm-driven catalog and is the fallback for machine types catalogued in SQL.
|
3. **`Description` mapping — DECIDED:** `Message`, falling back to `AlarmTypeName` when `Message` is empty.
|
||||||
|
|
||||||
3. **`Description` mapping.** Legacy `Description` came from the MXAccess `DescAttrName` tag. `AlarmStateChanged` has no dedicated description; closest is `Message` (per-band operator text) / `AlarmTypeName` / `Category`. **Recommend `Message`, fall back to `AlarmTypeName`.** Confirm acceptable.
|
4. **`AckDT` mapping — DECIDED: enrich the native mirror now.** Additive `AckTime` on `AlarmStateChanged` + vendored proto + `native_alarm_state` (survives failover). True source ack time where available (OPC UA A&C); DCL observation time of the ack transition where the source lacks one (MxGateway); null while unacked; cleared on re-raise. See §5.2.
|
||||||
|
|
||||||
4. **`AckDT` mapping.** No explicit ack-timestamp in `AlarmStateChanged` (`Timestamp` = last change, `OriginalRaiseTime` = raise). For acked alarms we can't precisely fill `AckDT`. Options: (a) leave `null`; (b) use `Timestamp` when `Acknowledged` (approximate); (c) enrich the native mirror to carry an ack timestamp (larger change). **Recommend (a)** for v1, (c) as a follow-up if MES depends on it.
|
5. **Script naming — DECIDED: `SimpleAlarmStatus` / `AlarmStatus`** (match the endpoint names; template-agnostic contract any future machine template can implement). With the MESReceiver version dropped there is no parallel definition, so no override-resolution concern remains.
|
||||||
|
|
||||||
5. **Script naming / override resolution.** Keep script names **identical** across both templates (`SimpleAlarmStatus`, `AlarmStatus`) so the inbound method is template-agnostic (`Call("AlarmStatus")`), and CvdReactor's root-level definition is what `Route.Call` resolves for CvdReactor instances. (Alternative: `Mes`-prefixed CvdReactor scripts like `MesMoveIn`.) **Recommend identical names**; confirm `Route.Call` resolves a CvdReactor root script over any composed-module script of the same name (verify during impl — the MoveIn precedent says yes).
|
6. **Side scoping — DECIDED (all parts).** CvdReactor-only: `_A` ⇒ Left, `_B` ⇒ Right; `_LT` stripped before reading the side (leak-test sources still included). (a) shared `ReactorAlarms` (`Z28061.`) **included on both sides** — reactor-wide faults apply regardless of side. (b) missing suffix: `SimpleAlarmStatus` **errors** on a SAPID without `_A`/`_B` (matches `MesMoveIn`); `AlarmStatus` selected by `Code`/`ZTag`/`MachineID` (no SAPID) **returns all sources**; a SAPID selector without a suffix errors.
|
||||||
|
|
||||||
6. **Side scoping — DECIDED, CvdReactor-only.** Only the **CvdReactor** override routes off the SAPID suffix: `_A` ⇒ Left, `_B` ⇒ Right; `_LT` is ignored (stripped before reading the side; leak-test sources still included). Mirrors `MesMoveIn`. The generic **MESReceiver** version and the inbound method do **not** parse or require the suffix — they stay whole-machine (legacy). Two residual sub-choices remain: (a) **shared `ReactorAlarms` (`Z28061.`) included on both sides** (current plan) vs strictly side-local — confirm; (b) **missing `_A`/`_B` suffix** — the **CvdReactor** `SimpleAlarmStatus` **errors** (matches `MesMoveIn`); the **CvdReactor** `AlarmStatus` selected by `Code`/`ZTag`/`MachineID` (no SAPID) **returns all sources** rather than erroring; the MESReceiver version never errors on a missing suffix — confirm acceptable.
|
7. **Roles / auth — DECIDED:** no new roles; create `AlarmStatus` as Designer via CLI; authorize the **existing MES API key** (the one already calling `MesMoveIn`/`MesMoveOut`) for both alarm endpoints — one key per external system.
|
||||||
|
|
||||||
7. **New method roles / auth.** `AlarmStatus` is a new inbound `ApiMethod` (`X-API-Key`); creating it requires `Roles.Designer`. No new global roles. Confirm the API key in use is authorized.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -227,26 +234,39 @@ These are the Socratic checkpoints — they change script bodies and/or the cont
|
|||||||
|
|
||||||
> Two artifact classes: **[repo]** = source/tests/docs committed to git; **[deployed]** = inbound methods / template scripts pushed to the cluster via CLI/UI (not in repo). The user said "don't execute yet" — this is the ordered plan only.
|
> Two artifact classes: **[repo]** = source/tests/docs committed to git; **[deployed]** = inbound methods / template scripts pushed to the cluster via CLI/UI (not in repo). The user said "don't execute yet" — this is the ordered plan only.
|
||||||
|
|
||||||
**Phase 1 — Enabling `Alarms` script API [repo]**
|
**Phase 1 — Enabling `Alarms` script API + AckTime mirror enrichment [repo]** — ✅ **DONE 2026-08-01**
|
||||||
1. Add `AlarmsAccessor` + `ScriptAlarm` to the runtime context (`ScriptRuntimeContext`) — local Ask to the Instance Actor; project `AlarmStateChanged` → `ScriptAlarm`. Add internal request/response message if not reusing `DebugSnapshotRequest`.
|
|
||||||
2. Mirror the stub on `ScriptCompileSurface` (and confirm `TriggerCompileSurface` not needed — trigger expressions don't read alarms).
|
1. ✅ **DONE 2026-08-01** — **AckTime enrichment (§6.4):** additive `AckTime` on `AlarmStateChanged`, the vendored `AlarmStateUpdate` proto (manual regen), `native_alarm_state` persistence, and the DCL ack-transition stamping (source ack time where supplied, else observation time).
|
||||||
3. Confirm/extend `ScriptTrustPolicy` allow-list so `Alarms` is permitted; no new forbidden APIs.
|
- `AlarmStateChanged.AckTime` (init-only, `null` default) + `NativeAlarmTransition.AckTime` (trailing optional positional — all 14-arg call sites unchanged).
|
||||||
4. Unit tests: runtime accessor projection (active/acked/severity/timestamps), compile-surface compiles a representative `Alarms.CurrentAsync()` script, trust-policy accepts it.
|
- Proto: **field 24** `google.protobuf.Timestamp ack_time` on `AlarmStateUpdate`; regenerated with `docker/regen-proto.sh sitestream` (csproj diff verified empty). Packed/unpacked by `StreamRelayActor` / `SiteStreamGrpcClient`; an absent Timestamp round-trips to `null`.
|
||||||
5. Doc: update `docs/requirements/Component-SiteRuntime.md` (+ Script Analysis #25 surface list) to document the `Alarms` accessor; note it in `Component-InboundAPI.md` routing examples.
|
- Stamping rule (both protocols): non-null **only** while the condition is active AND acknowledged — that one predicate yields "null while unacked", "cleared on re-raise", and no phantom ack on the MxGateway return-to-normal (which maps `INACTIVE → Acknowledged = true`). Lives in the pure `OpcUaAlarmMapper.DeriveAckTime` / `MxGatewayAlarmMapper.DeriveAckTime`.
|
||||||
|
- OPC UA gets a **true source ack instant**: new SelectClause **index 18** = `AcknowledgeableConditionType/AckedState/TransitionTime`, appended so indices 0–17 keep their meaning; falls back to the event's `Time` when the server omits it. MxGateway uses the ack transition's own timestamp (its feed carries no ack time), and an `ACTIVE_ACKED` re-subscribe snapshot restores one from `LastTransitionTimestamp`.
|
||||||
|
- Persistence: rides `native_alarm_state`'s existing `metadata_json` blob, **not** a new column — that table is `RegisterReplicated` in `SiteLocalDbSetup` and LocalDb builds its CDC triggers from the column list at registration time, so an additive JSON property changes no schema, no triggers and no replication contract (`metadata_json` is exactly the extension point UA4 added). Pre-AckTime rows deserialize it as `null`.
|
||||||
|
2. ✅ **DONE 2026-08-01** — Add `AlarmsAccessor` + `ScriptAlarm` to the runtime context (`ScriptRuntimeContext`) — local Ask to the Instance Actor; project `AlarmStateChanged` → `ScriptAlarm` (incl. `AckTime`). Add internal request/response message if not reusing `DebugSnapshotRequest`.
|
||||||
|
- Dedicated `GetAlarmSnapshotRequest`/`GetAlarmSnapshotResponse` (Commons `Messages/Instance`) rather than reusing `DebugSnapshotRequest`, which would materialise every attribute value on every alarm poll. Served from the same `BuildAlarmStatesSnapshot()` the Debug View uses, so the two can never disagree.
|
||||||
|
- `AlarmsAccessor` sits in `ScopeAccessors.cs` beside the other accessors but is **not scope-prefixed** — alarm identity is not a scope-relative attribute name, so every scope sees the whole list. Exposed as `ScriptRuntimeContext.Alarms` and the top-level `ScriptGlobals.Alarms`.
|
||||||
|
3. ✅ **DONE 2026-08-01** — Mirror the stub on `ScriptCompileSurface` (and confirm `TriggerCompileSurface` not needed — trigger expressions don't read alarms).
|
||||||
|
- `CompileAlarmsAccessor` returns the **same** `ScriptAlarm` type as the runtime (Commons is already in `DefaultAssemblies`), so field access binds identically at the gate and at the site. `TriggerCompileSurface` confirmed not needed.
|
||||||
|
- Also mirrored on the **third** hand-maintained surface, the Central UI Test-Run `SandboxScriptHost` — without it the design page would false-flag CS1061 on scripts the deploy gate accepts. It throws a labelled `ScriptSandboxException` at run time (no central route to per-instance alarm state) rather than returning an empty list that would read as "nothing is in alarm".
|
||||||
|
4. ✅ **DONE 2026-08-01** — Confirm/extend `ScriptTrustPolicy` allow-list so `Alarms` is permitted; no new forbidden APIs.
|
||||||
|
- **No change needed, and the reason is structural:** the trust boundary is a deny-list over API roots, not an allow-list of context members. Pinned by a test asserting no `ForbiddenScopes` entry prefixes the Commons script-surface namespace, so a future deny-list entry cannot silently make `ScriptAlarm` untouchable.
|
||||||
|
5. ✅ **DONE 2026-08-01** — Unit tests: runtime accessor projection (active/acked/severity/timestamps/AckTime), AckTime stamping + failover persistence, compile-surface compiles a representative `Alarms.CurrentAsync()` script, trust-policy accepts it.
|
||||||
|
- New `AlarmsAccessorTests` (6), `NativeAlarmActor` AckTime emit/rehydrate/pre-AckTime-row (3), `InstanceActor` alarm-snapshot (2), mapper AckTime (4 OPC UA + 6 MxGateway), proto round-trip (1), Commons additive/back-compat (4), compile-surface + trust (4), `SandboxScriptHost` diagnose-clean (1). `AlarmsAccessor` added to the `CompileSurfaceParityTests` mirror pairs; the OPC UA SelectClause count lock-in went 18 → 19 with an index-18 assertion (intended — the clause is appended).
|
||||||
|
6. ✅ **DONE 2026-08-01** — Doc: update `Component-SiteRuntime.md` + `Component-DataConnectionLayer.md` (native-mirror AckTime) + Script Analysis #25 surface list; note the accessor in `Component-InboundAPI.md` routing examples.
|
||||||
|
- Also updated the `CLAUDE.md` native-alarm bullet. The Inbound API note records the *negative* decision: no `Route.To(...).GetAlarms(...)` verb — alarm reads go through a routed site script so the filtering happens where the data lives.
|
||||||
|
|
||||||
**Phase 2 — Inbound methods [deployed] + doc [repo]**
|
**Phase 2 — Inbound methods [deployed] + doc [repo]**
|
||||||
6. Update `SimpleAlarmStatusRequest` (id 9) body to the §5.1 router (validate via design page first to avoid the stale-handler trap — see memory `inbound-noncompiling-update-keeps-old-handler`).
|
7. Update `SimpleAlarmStatusRequest` (id 9) body to the §5.1 router incl. the not-supported-machine catch (validate via design page first to avoid the stale-handler trap — see memory `inbound-noncompiling-update-keeps-old-handler`).
|
||||||
7. Create `AlarmStatus` method (params/return/script per §5.1).
|
8. Create `AlarmStatus` method (params/return/script per §5.1); authorize the existing MES API key for both methods (§6.7).
|
||||||
8. Doc the two endpoints in `docs/requirements/Component-InboundAPI.md` (or a dedicated MES-integration note) cross-referencing the legacy spec.
|
9. Doc the two endpoints in `docs/requirements/Component-InboundAPI.md` (or a dedicated MES-integration note) cross-referencing the legacy spec.
|
||||||
|
|
||||||
**Phase 3 — Site template scripts [deployed]**
|
**Phase 3 — Site template scripts [deployed]** *(MESReceiver task removed — §6.2 decided dropped)*
|
||||||
9. Add `SimpleAlarmStatus` + `AlarmStatus` to **MESReceiver** (BTDB `MachineAlarm` via `Database.Connection("BTDB")`), per §6.2 decision.
|
10. Add `SimpleAlarmStatus` + `AlarmStatus` to **CvdReactor** (native sources via `Alarms.CurrentAsync()`, MES band constants), per §5.3.
|
||||||
10. Add override `SimpleAlarmStatus` + `AlarmStatus` to **CvdReactor** (native sources via `Alarms.CurrentAsync()`), per §5.3.
|
11. `template validate` (script compilation gate) before redeploy; redeploy affected instances.
|
||||||
11. `template validate` both templates (script compilation gate) before redeploy; redeploy affected instances.
|
|
||||||
|
|
||||||
**Phase 4 — Verify**
|
**Phase 4 — Verify**
|
||||||
12. Build affected projects + run targeted tests (per memory `targeted-tests-not-full-suite`).
|
12. Build affected projects + run targeted tests (per memory `targeted-tests-not-full-suite`).
|
||||||
13. Live smoke against a real reactor instance (see §8) — both endpoints, success + machine-not-found + filtered.
|
13. Live smoke against a real reactor instance (see §8) — both endpoints, success + machine-not-found + not-supported-machine + filtered. Requires the Galaxy MES-band priorities (§6.1) to be set for a meaningful flagged-only result.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -261,10 +281,10 @@ These are the Socratic checkpoints — they change script bodies and/or the cont
|
|||||||
|
|
||||||
## 9. Out of scope / follow-ups
|
## 9. Out of scope / follow-ups
|
||||||
|
|
||||||
- True `IsFlaggedForMES` for native alarms (per-source MES allow-list) — §6.1 option (b).
|
- ~~True `IsFlaggedForMES` allow-list~~ — resolved by the §6.1 severity-band decision (no follow-up needed).
|
||||||
- Precise `AckDT` (enrich native mirror with an ack timestamp) — §6.4 option (c).
|
- ~~Precise `AckDT` follow-up~~ — pulled INTO scope by §6.4 (mirror enriched now).
|
||||||
- A live/aggregated central alarm store or stream (the M7 follow-up) — these endpoints stay pull-based, per-instance, like the Alarm Summary page.
|
- A live/aggregated central alarm store or stream (the M7 follow-up) — these endpoints stay pull-based, per-instance, like the Alarm Summary page.
|
||||||
- MESReceiver live-state path if §6.2 (c) is chosen and MES later needs live status from non-native machines.
|
- Alarm-status support for non-CvdReactor machine types (any future template just implements `SimpleAlarmStatus`/`AlarmStatus` root scripts against the same contract).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -272,13 +292,14 @@ These are the Socratic checkpoints — they change script bodies and/or the cont
|
|||||||
|
|
||||||
| Artifact | Type | Change |
|
| Artifact | Type | Change |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ScriptRuntimeContext` | [repo] | New `Alarms` accessor + `ScriptAlarm`; local alarm-snapshot Ask |
|
| `AlarmStateChanged` + vendored `AlarmStateUpdate` proto (**field 24**) + `native_alarm_state` (`metadata_json`) + DCL stamping (OPC UA SelectClause **index 18**) | [repo] ✅ | Additive `AckTime` enrichment (§6.4) |
|
||||||
| `ScriptCompileSurface` | [repo] | Mirror `Alarms` stub |
|
| `ScriptRuntimeContext` + `ScriptGlobals` | [repo] ✅ | New `Alarms` accessor + `ScriptAlarm` (incl. `AckTime`); local alarm-snapshot Ask |
|
||||||
| `ScriptTrustPolicy` (#25) | [repo] | Allow `Alarms` member (verify) |
|
| `ScriptCompileSurface` + Central UI `SandboxScriptHost` | [repo] ✅ | Mirror `Alarms` stub on both design-time surfaces |
|
||||||
| Internal alarm-snapshot message (or reuse `DebugSnapshotRequest`) | [repo] | Additive |
|
| `ScriptTrustPolicy` (#25) | [repo] ✅ | Verified — **no change needed** (deny-list, not member allow-list); pinned by test |
|
||||||
| Site Runtime + Script Analysis + Inbound API docs | [repo] | Document `Alarms` accessor + endpoints |
|
| `GetAlarmSnapshotRequest`/`Response` (Commons) | [repo] ✅ | New, additive — chosen over reusing `DebugSnapshotRequest` |
|
||||||
| Unit tests (SiteRuntime / ScriptAnalysis / InboundAPI) | [repo] | New |
|
| Site Runtime + DCL + Script Analysis + Inbound API docs + `CLAUDE.md` | [repo] ✅ | Document `Alarms` accessor, `AckTime`, endpoints |
|
||||||
| `SimpleAlarmStatusRequest` (id 9) | [deployed] | Stub → real router |
|
| Unit tests (Commons / DCL / SiteRuntime / ScriptAnalysis / Communication / CentralUI) | [repo] ✅ | New |
|
||||||
| `AlarmStatus` (new) | [deployed] | New inbound method |
|
| `SimpleAlarmStatusRequest` (id 9) | [deployed] | Stub → real router (+ not-supported catch) |
|
||||||
| `MESReceiver.SimpleAlarmStatus` / `.AlarmStatus` | [deployed] | New BTDB-driven scripts |
|
| `AlarmStatus` (new) | [deployed] | New inbound method, existing MES key authorized |
|
||||||
| `CvdReactor.SimpleAlarmStatus` / `.AlarmStatus` | [deployed] | New native-source override scripts |
|
| `CvdReactor.SimpleAlarmStatus` / `.AlarmStatus` | [deployed] | New native-source scripts (MES band 900–999) |
|
||||||
|
| Galaxy alarm priorities | [external] | MES-relevant CvdReactor alarms configured into 900–999 (§6.1 prerequisite) |
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ All 7 fix-now items landed via PLAN-04/05/06/07/08 (verified in review 08 round
|
|||||||
| 8 | Hash-chain tamper evidence (T1); CLI verify-chain is a no-op stub | audit-log roadmap :12 | v1.x by locked decision; append-only DB roles are the control | Compliance requirement for cryptographic tamper evidence |
|
| 8 | Hash-chain tamper evidence (T1); CLI verify-chain is a no-op stub | audit-log roadmap :12 | v1.x by locked decision; append-only DB roles are the control | Compliance requirement for cryptographic tamper evidence |
|
||||||
| 9 | Parquet audit archival (T2); endpoint returns 501 | AuditEndpoints.cs:204 | v1.x; 501 + CLI messaging are honest | AuditLog partition volume nears retention ceiling |
|
| 9 | Parquet audit archival (T2); endpoint returns 501 | AuditEndpoints.cs:204 | v1.x; 501 + CLI messaging are honest | AuditLog partition volume nears retention ceiling |
|
||||||
| 11 | Central-persisted OPC UA cert-trust audit | m7 follow-ups | Broadcast-to-both-nodes covers HA | Governance/audit requirement for trust decisions |
|
| 11 | Central-persisted OPC UA cert-trust audit | m7 follow-ups | Broadcast-to-both-nodes covers HA | Governance/audit requirement for trust decisions |
|
||||||
| 12 | Native-alarm-source-override CSV import — **Central UI `InstanceConfigure` upload affordance only** (CLI + Management API + parser shipped 2026-07-10, see Resolved) | m7 follow-ups | CLI/API path closes the operator parity gap; the Blazor upload button is polish | First request to bulk-import native sources from the UI rather than the CLI |
|
|
||||||
| 17 | Unified notifications+site-calls outbox page | stillpending :118 | Explicit M9 decision to keep two pages | Operator confusion reports |
|
| 17 | Unified notifications+site-calls outbox page | stillpending :118 | Explicit M9 decision to keep two pages | Operator confusion reports |
|
||||||
| 18 | Folder drag-drop | same, [PERM] | Permanently closed; menu reorder shipped | — (closed) |
|
|
||||||
| 19 | Bundle signing / cluster-to-cluster pull / differential bundles | transport-design :402 | v1 manifest hash + AES-GCM held sufficient | Non-repudiation requirement across orgs |
|
| 19 | Bundle signing / cluster-to-cluster pull / differential bundles | transport-design :402 | v1 manifest hash + AES-GCM held sufficient | Non-repudiation requirement across orgs |
|
||||||
|
| 23 | Live LDAP group-membership re-query for an active session | `docs/requirements/Component-Security.md` :61-69 (+ :78-79) | Blocked on an external package. The mid-session refresh re-maps the **stored** groups against the central DB with **no LDAP call**, so a directory group-membership change lands only at next login. A live re-query needs a passwordless service-account group-search method on the shared `ZB.MOM.WW.Auth.Ldap` library — an external NuGet `PackageReference` (`src/ZB.MOM.WW.ScadaBridge.Security/…csproj:23`) exposing only `AuthenticateAsync(username, password, ct)`. Central role-mapping/scope changes still apply within ~15 min (`RoleRefreshThresholdMinutes`). | `ZB.MOM.WW.Auth.Ldap` gains a standalone group-search API, or a requirement that a directory-side group revocation take effect mid-session rather than at next login |
|
||||||
|
| 24 | M8 large-bundle performance hardening | `docs/plans/2026-06-15-stillpending-completion-design.md:106` — "Small follow-ups logged (not blocking): … large-bundle/perf hardening" | Logged as a non-blocking follow-up when M8 shipped and never given an artifact: **no plan, no task entry, no perf/load test exists** (`tests/…Transport.Tests/Import/BundleImporterLoadTests.cs` is a `LoadAsync` unit suite despite the name). No measured problem; the only sizing controls in place are the 5-minute CLI transport timeout, `LineDiffer`'s `MaxInputLines`=4000 summary-only cap, and `MaxConcurrentImportSessions`=8. | First real bundle that times out, exhausts memory, or makes the import wizard's diff step unusable |
|
||||||
|
| 25 | Phase-8 WP-4 target-scale load test (10 sites × 500 instances × 75 tags = 37,500 subscriptions/site, 375,000 total) | `docs/plans/phase-8-production-readiness.md:152-170` (WP-4) + `:314-320` (test protocol); status claimed in `docs/plans/phase-8-checklist.md` | **Claimed complete but unevidenced.** The whole WP-4 deliverable is a **107-byte** checklist stub asserting "Status: Complete / Tests: All passing / Build: 0 errors, 0 warnings" with no per-work-package results and no linked run. Nearest real coverage is arithmetic/aggregation only — `PerformanceTests/StaggeredStartupTests.cs` (`TagCapacity_75TagsPer500Machines_37500Total`, 500-instances-over-10-sites distribution) and `HealthAggregationTests` (10-site report aggregation) — plus a **single-subscriber** 100k-event `Streaming/SiteStreamThroughputTests.cs`. No sustained multi-site run exists anywhere in `tests/` or `docker/`. | Before any production go-live at target scale; or the first site approaching ~500 instances / ~37.5k subscriptions |
|
||||||
|
| 26 | Ipsen MES MoveIn tail: leak-test (`-LT`) receivers + routing, PLC-output-flag writes, `Z28062` BTDB data completeness | `docs/plans/2026-06-16-ipsen-mes-movein.md:409` ("Out of scope (future)"); design `2026-06-16-ipsen-mes-movein-design.md:58-60, :196-198` | Customer-site scope, not a platform gap. `-LT` routing needs an MES-receiver child + Galaxy reference that do not exist on the reactor template (any `-LT`/unknown suffix returns `WasSuccessful=false` with an "unsupported side/target" message by decision); `MoveInComplete`/`Successful`/`ErrorText` are **PLC-owned** by locked decision, so ScadaBridge deliberately does not write them; `Z28062` completeness is an operational data fix, not code. Note the separate alarm-status path already handles the suffix — `_LT` is stripped before side-scoping (`2026-06-30-mes-alarm-status-api.md:158`). | Ipsen creates the leak-test receiver + Galaxy reference, or asks ScadaBridge to own the PLC-output flags — otherwise a **candidate won't-do** (`[PERM]`) at the next Ipsen scope review |
|
||||||
|
| 27 | External-system per-system retry config (`MaxRetries`/`RetryDelay`) never reaches sites, and has no CLI/management surface | Found live 2026-08-01 (rig session, #11 gRPC live checks) | Two stacked gaps: (a) `ExternalSystemArtifact` (Commons) carries `TimeoutSeconds` but NOT `MaxRetries`/`RetryDelay`, and the site `external_systems` table has no such columns — so a centrally-configured retry policy is silently ignored on sites; every cached call buffers with the S&F default (`DefaultMaxRetries` 50 × `DefaultRetryInterval` 30s ≈ 25 min to park). (b) `Create/UpdateExternalSystemCommand` don't expose the fields either — the only way to set them today is a direct DB edit of `ExternalSystemDefinitions`. Transport bundles DO carry them (arch-review 05 "ES retry config"), which masks the gap in export/import round-trips. Fix is additive: extend the artifact + site schema + apply path, and add `--max-retries`/`--retry-delay` to the CLI. | First operator who tunes retry policy on an external system and expects site cached calls to honor it |
|
||||||
|
| 28 | Health-dashboard "Trigger failover" confirm dialog's confirm button is labeled **"Delete"** | Found live 2026-08-01 (rig session, #11 TriggerSiteFailover check) | The DialogService confirmation host's default destructive-action label leaks through — the dialog copy is correct but the red confirm button says "Delete" for a failover. One-line fix: pass an explicit confirm label ("Fail over") at the Health-dashboard call site (and audit other confirm-dialog call sites for the same default). | Next Central UI session |
|
||||||
|
|
||||||
## Resolved (verified against the code 2026-07-10)
|
## Resolved (verified against the code 2026-07-10)
|
||||||
Rows removed from the Deferred table above once confirmed shipped. Kept here for traceability.
|
Rows removed from the Deferred table above once confirmed shipped. Kept here for traceability.
|
||||||
@@ -26,11 +30,12 @@ Rows removed from the Deferred table above once confirmed shipped. Kept here for
|
|||||||
|---|------|-----------|
|
|---|------|-----------|
|
||||||
| 10 | Aggregated live alarm stream for Alarm Summary | Shipped 2026-07-10 (`docs/plans/2026-07-10-aggregated-live-alarm-stream-plan.md`): a **transient, in-memory** per-site central live alarm cache (`ISiteAlarmLiveCache`/`SiteAlarmLiveCacheService` + per-site `SiteAlarmAggregatorActor`) fed by a new site-wide, alarm-only `SubscribeSite` gRPC stream (`SiteStreamManager.SubscribeSiteAlarms`), seed-then-stream with dedup + NodeA↔NodeB re-seed + periodic reconcile. Alarm Summary is now live-cache-driven (`AlarmSummaryService.BuildFromLiveAlarms`) with the 15s poll retained as fallback + `NotReporting` authority. Honors the `[PERM]` no-central-store rule — nothing persisted (no EF table/migration). Options on `CommunicationOptions` (eagerly validated) + two `ScadaBridgeTelemetry` signals. |
|
| 10 | Aggregated live alarm stream for Alarm Summary | Shipped 2026-07-10 (`docs/plans/2026-07-10-aggregated-live-alarm-stream-plan.md`): a **transient, in-memory** per-site central live alarm cache (`ISiteAlarmLiveCache`/`SiteAlarmLiveCacheService` + per-site `SiteAlarmAggregatorActor`) fed by a new site-wide, alarm-only `SubscribeSite` gRPC stream (`SiteStreamManager.SubscribeSiteAlarms`), seed-then-stream with dedup + NodeA↔NodeB re-seed + periodic reconcile. Alarm Summary is now live-cache-driven (`AlarmSummaryService.BuildFromLiveAlarms`) with the 15s poll retained as fallback + `NotReporting` authority. Honors the `[PERM]` no-central-store rule — nothing persisted (no EF table/migration). Options on `CommunicationOptions` (eagerly validated) + two `ScadaBridgeTelemetry` signals. |
|
||||||
| 7 | SecuredWrite audit rows leave SourceNode NULL | Resolved (PLAN-07): `ManagementActor.EmitSecuredWriteAuditAsync` routes through `ICentralAuditWriter`, which stamps `SourceNode` (`central-a`/`central-b`) from `INodeIdentityProvider`. |
|
| 7 | SecuredWrite audit rows leave SourceNode NULL | Resolved (PLAN-07): `ManagementActor.EmitSecuredWriteAuditAsync` routes through `ICentralAuditWriter`, which stamps `SourceNode` (`central-a`/`central-b`) from `INodeIdentityProvider`. |
|
||||||
| 12 (CLI/API) | Native-alarm-source-override CSV import | Shipped 2026-07-10: shared `CsvLineSplitter`, `NativeAlarmSourceOverrideCsvParser`, bulk all-or-nothing `SetInstanceNativeAlarmSourceOverridesCommand` + ManagementActor handler (Deployer-gated), CLI `instance native-alarm-source import --file`, parser/CLI/handler tests. **UI upload affordance still pending — see row 12 above.** |
|
| 12 (CLI/API) | Native-alarm-source-override CSV import | Shipped 2026-07-10: shared `CsvLineSplitter`, `NativeAlarmSourceOverrideCsvParser`, bulk all-or-nothing `SetInstanceNativeAlarmSourceOverridesCommand` + ManagementActor handler (Deployer-gated), CLI `instance native-alarm-source import --file`, parser/CLI/handler tests. **UI upload affordance shipped 2026-08-01** — second `InputFile` on the `InstanceConfigure` Native Alarm Source Overrides card reusing the shared parser, mirroring the attribute importer's UX and the server's all-or-nothing merge semantics (`InstanceConfigureNativeAlarmCsvImportTests`); row 12 removed from the Deferred table. |
|
||||||
| 13 | WaitForAttribute quality-gated ("Good"-only) mode | Already implemented (Commons `WaitForAttribute.RequireGoodQuality`, enforced in `InstanceActor`, threaded through `ScriptRuntimeContext`, tested in `InstanceActorWaitForAttributeTests`). Stale "planned enhancement" doc line corrected 2026-07-10. |
|
| 13 | WaitForAttribute quality-gated ("Good"-only) mode | Already implemented (Commons `WaitForAttribute.RequireGoodQuality`, enforced in `InstanceActor`, threaded through `ScriptRuntimeContext`, tested in `InstanceActorWaitForAttributeTests`). Stale "planned enhancement" doc line corrected 2026-07-10. |
|
||||||
| 14 | WaitForAttribute in Test-Run sandbox | Shipped 2026-07-10 (full fidelity): sandbox `Attributes.WaitAsync`/`WaitForAsync` (value-equality) route to the bound instance via `ISandboxInstanceGateway.WaitForAttributeAsync` → the existing `CommunicationService.RouteToWaitForAttributeAsync` cross-site route. Additive `RouteToWaitForAttributeRequest.RequireGoodQuality` (honored by the site handler) makes quality-gated waits route too. **Predicate-form waits stay unsupported** (an in-process lambda can't be routed) and throw a labelled `ScriptSandboxException`. Tests: sandbox accessor routing (CentralUI), site-handler quality-flag threading (SiteRuntime). |
|
| 14 | WaitForAttribute in Test-Run sandbox | Shipped 2026-07-10 (full fidelity): sandbox `Attributes.WaitAsync`/`WaitForAsync` (value-equality) route to the bound instance via `ISandboxInstanceGateway.WaitForAttributeAsync` → the existing `CommunicationService.RouteToWaitForAttributeAsync` cross-site route. Additive `RouteToWaitForAttributeRequest.RequireGoodQuality` (honored by the site handler) makes quality-gated waits route too. **Predicate-form waits stay unsupported** (an in-process lambda can't be routed) and throw a labelled `ScriptSandboxException`. Tests: sandbox accessor routing (CentralUI), site-handler quality-flag threading (SiteRuntime). |
|
||||||
| 15 | BrowseNext final-page signal not surfaced | Already surfaced (M7 browse work): `RealOpcUaClient` sets `Truncated=false`/`ContinuationToken=null` on the last page; `BrowseNodeResult` carries both; `TreeRow.razor` renders "Load more" only when a continuation token remains — no wasted BrowseNext. |
|
| 15 | BrowseNext final-page signal not surfaced | Already surfaced (M7 browse work): `RealOpcUaClient` sets `Truncated=false`/`ContinuationToken=null` on the last page; `BrowseNodeResult` carries both; `TreeRow.razor` renders "Load more" only when a continuation token remains — no wasted BrowseNext. |
|
||||||
| 16 | StubOpcUaClient throws on browse | Already resolved: `StubOpcUaClient` supports browse + address-space search, covered by `StubOpcUaClientBrowseTests`/`StubOpcUaClientSearchTests`. |
|
| 16 | StubOpcUaClient throws on browse | Already resolved: `StubOpcUaClient` supports browse + address-space search, covered by `StubOpcUaClientBrowseTests`/`StubOpcUaClientSearchTests`. |
|
||||||
|
| 18 | Folder drag-drop | Closed — **permanently deferred (`[PERM]`)** by the M9 decision (`docs/plans/2026-06-15-stillpending-completion-design.md:122`): menu-based reorder (T23) shipped instead, and the folder-hierarchy design fixed the reorganization UX as "right-click context menus only (no drag-drop)" (`2026-05-11-templates-folder-hierarchy-design.md:27`). Row removed from the Deferred table 2026-08-01 — nothing left to revisit. |
|
||||||
| 20 | Deployment EXPIRED-row purge | Already resolved (PLAN-04): `PendingDeploymentPurgeActor` central singleton (spawned in `AkkaHostedService`) ticks `IDeploymentManagerRepository.PurgeExpiredPendingDeploymentsAsync` every `CommunicationOptions.PendingDeploymentPurgeInterval` (default 1h), options-validated, tested. |
|
| 20 | Deployment EXPIRED-row purge | Already resolved (PLAN-04): `PendingDeploymentPurgeActor` central singleton (spawned in `AkkaHostedService`) ticks `IDeploymentManagerRepository.PurgeExpiredPendingDeploymentsAsync` every `CommunicationOptions.PendingDeploymentPurgeInterval` (default 1h), options-validated, tested. |
|
||||||
| 21 | SiteAuditBacklogReporter threshold consolidation | Shipped 2026-07-10: `SqliteAuditWriterOptions.BacklogPollIntervalSeconds` (default 30) now drives the reporter's poll cadence; explicit ctor override still wins (tests), non-positive falls back to the 30 s default. Cadence tests added; stale "hard-code / follow-up" class-doc corrected. |
|
| 21 | SiteAuditBacklogReporter threshold consolidation | Shipped 2026-07-10: `SqliteAuditWriterOptions.BacklogPollIntervalSeconds` (default 30) now drives the reporter's poll cadence; explicit ctor override still wins (tests), non-positive falls back to the 30 s default. Cadence tests added; stale "hard-code / follow-up" class-doc corrected. |
|
||||||
| 22 | KPI history hourly rollups | Shipped 2026-07-10 (`docs/plans/2026-07-10-kpi-history-hourly-rollups-plan.md`, T1–T8): new `KpiRollupHourly` table (migration `20260710153953`) folded by a third recorder tick (`kpi-rollup`, `RollupInterval` default 1h) over a re-folded `RollupLookbackHours` window via an idempotent, failover-self-healing upsert; per-metric gauge-vs-rate aggregation (`KpiMetricAggregationCatalog`); a one-shot backfill of the retention window on start; raw-vs-rollup query routing by `RollupThresholdHours` (default 168h); longer rollup retention (`RollupRetentionDays` default 365 ≥ `RetentionDays`, dual daily purge); and 30 d / 90 d trend windows added to the four surfaces. Options + validator, docs (`Component-KpiHistory.md`), and tests shipped. |
|
| 22 | KPI history hourly rollups | Shipped 2026-07-10 (`docs/plans/2026-07-10-kpi-history-hourly-rollups-plan.md`, T1–T8): new `KpiRollupHourly` table (migration `20260710153953`) folded by a third recorder tick (`kpi-rollup`, `RollupInterval` default 1h) over a re-folded `RollupLookbackHours` window via an idempotent, failover-self-healing upsert; per-metric gauge-vs-rate aggregation (`KpiMetricAggregationCatalog`); a one-shot backfill of the retention window on start; raw-vs-rollup query routing by `RollupThresholdHours` (default 168h); longer rollup retention (`RollupRetentionDays` default 365 ≥ `RetentionDays`, dual daily purge); and 30 d / 90 d trend windows added to the four surfaces. Options + validator, docs (`Component-KpiHistory.md`), and tests shipped. |
|
||||||
@@ -41,7 +46,8 @@ Rows removed from the Deferred table above once confirmed shipped. Kept here for
|
|||||||
| Communication → HealthMonitoring layering (ICentralHealthAggregator consumed by CentralCommunicationActor.cs:351) | Moving the interface + SiteHealthState to Commons ripples across 5 projects for a cosmetic inversion | Next breaking change to ICentralHealthAggregator |
|
| Communication → HealthMonitoring layering (ICentralHealthAggregator consumed by CentralCommunicationActor.cs:351) | Moving the interface + SiteHealthState to Commons ripples across 5 projects for a cosmetic inversion | Next breaking change to ICentralHealthAggregator |
|
||||||
| docs/components reference docs for ScriptAnalysis, KpiHistory, DelmiaNotifier | Reference docs are substantial (StyleGuide-conformant); README claim scoped instead (PLAN-08 Task 10) | Next doc-writing session touching those components |
|
| docs/components reference docs for ScriptAnalysis, KpiHistory, DelmiaNotifier | Reference docs are substantial (StyleGuide-conformant); README claim scoped instead (PLAN-08 Task 10) | Next doc-writing session touching those components |
|
||||||
| Test-coverage backfill: SiteCallAudit.Tests (31 tests/1.6k LOC), DeploymentManager.Tests | No defect identified; coverage partly lives in ManagementService/Host/Integration suites | First regression escaping either component |
|
| Test-coverage backfill: SiteCallAudit.Tests (31 tests/1.6k LOC), DeploymentManager.Tests | No defect identified; coverage partly lives in ManagementService/Host/Integration suites | First regression escaping either component |
|
||||||
| Failover-timing + broader perf envelope (S&F drain rate, per-subscriber backpressure) | **Trigger fired 2026-07-08** — the "PLAN-01 rig landing" trigger fired when `tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/TwoNodeClusterFixture.cs` landed (before the placeholder harness even shipped), and went unnoticed (NF2). **Owner: PLAN-R2-01** — `archreview/plans/PLAN-R2-01-*.md` (failover-envelope measurement task) owns wiring `FailoverTimingTests` to the fixture rig or recording the true blocker. | Owned by PLAN-R2-01; this register row tracks the handoff only |
|
| ~~Failover-timing measurement (the "~25s total failover" envelope)~~ **RESOLVED 2026-08-01** — split out of the combined row and closed. `tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Failover/FailoverTimingTests.cs` is no longer a skipped placeholder: it runs as a live `[Fact]` (`Category=Performance`) on the real two-node in-process rig (`TwoNodeClusterFixture`, production `BuildHocon`) at production timings — 2s heartbeat / 10s failure-detection threshold / 15s stable-after — hard-killing the younger node and timing the survivor's member REMOVAL with singleton continuity asserted on the oldest. Delivered by **PLAN-R2-01 Task 4** (`archreview/plans/PLAN-R2-01-cluster-host-failover.md:226`). The oldest-crash direction is covered behaviorally by `SbrFailoverTests.AutoDown_HardCrashOfOldestNode_*` and by `docker/failover-drill.sh`. | The 2026-07-08 "PLAN-01 rig landing" trigger had fired unnoticed (NF2); PLAN-R2-01 T4 wired the placeholder to the fixture rig rather than recording a blocker. | Closed. |
|
||||||
|
| Broader perf envelope — **S&F drain rate + per-subscriber stream backpressure** (the still-open half of the former combined row) | Never measured, and no owner plan survives now that PLAN-R2-01 closed the failover half. `PerformanceTests` covers failover timing, staggered startup, health aggregation, audit hot-path latency and a **single-subscriber** 100k-event `Streaming/SiteStreamThroughputTests.cs` — nothing measures store-and-forward drain throughput, nor what a slow/stalled subscriber does to the per-subscriber buffering in `Communication/Actors/StreamRelayActor.cs` / `Grpc/SiteStreamGrpcServer.cs` with many subscribers attached. No defect observed; deferred as measurement-only work. | First field S&F backlog that fails to drain within an operator's patience, a slow gRPC subscriber degrading a site stream for others, or the WP-4 target-scale run (row 25) being scheduled — that run should absorb this |
|
||||||
|
|
||||||
## Deferred — operational risk (from the initiative tracker, folded in 2026-07-12)
|
## Deferred — operational risk (from the initiative tracker, folded in 2026-07-12)
|
||||||
Two live items previously tracked ONLY in `archreview/plans/00-MASTER-TRACKER.md`'s registry are folded in here (NF5) so this register is the single tracking place. The tracker's narrative subsections remain as the historical evidence.
|
Two live items previously tracked ONLY in `archreview/plans/00-MASTER-TRACKER.md`'s registry are folded in here (NF5) so this register is the single tracking place. The tracker's narrative subsections remain as the historical evidence.
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# Plan: Aggregated Live Alarm Stream for Alarm Summary (deferred #10)
|
# Plan: Aggregated Live Alarm Stream for Alarm Summary (deferred #10)
|
||||||
|
|
||||||
**Status:** Draft plan (not yet executed) — 2026-07-10
|
**Status:** **Delivered 2026-07-10** — shipped as designed (`SiteAlarmAggregatorActor`,
|
||||||
|
`ISiteAlarmLiveCache`, the `SubscribeSite` streaming RPC, and
|
||||||
|
`AlarmSummaryService.BuildFromLiveAlarms` are all in `src/`). The 15 s poll is retained as the
|
||||||
|
NotReporting authority behind the live stream, per the design below.
|
||||||
**Register row:** `docs/plans/2026-07-08-deferred-work-register.md` #10
|
**Register row:** `docs/plans/2026-07-08-deferred-work-register.md` #10
|
||||||
**Revisit trigger that fired this plan:** *"Alarm Summary latency complaints or >~50 instances/site."*
|
**Revisit trigger that fired this plan:** *"Alarm Summary latency complaints or >~50 instances/site."*
|
||||||
**Owning component:** Central UI (#9) + Central–Site Communication (#5); no new component.
|
**Owning component:** Central UI (#9) + Central–Site Communication (#5); no new component.
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# Plan: KPI History Hourly Rollups (deferred #22)
|
# Plan: KPI History Hourly Rollups (deferred #22)
|
||||||
|
|
||||||
**Status:** Draft plan (not yet executed) — 2026-07-10
|
**Status:** **Delivered 2026-07-10** — shipped as designed: the `KpiRollupHourly` entity +
|
||||||
|
EF migration `20260710153953_AddKpiRollupHourlyTable`, `KpiMetricAggregationCatalog`
|
||||||
|
(per-metric gauge-vs-rate fold), and the raw-vs-rollup query routing on `RollupThresholdHours`
|
||||||
|
are all in `src/`.
|
||||||
**Register row:** `docs/plans/2026-07-08-deferred-work-register.md` #22
|
**Register row:** `docs/plans/2026-07-08-deferred-work-register.md` #22
|
||||||
**Revisit trigger that fired this plan:** *"KpiSample query latency on dashboards."*
|
**Revisit trigger that fired this plan:** *"KpiSample query latency on dashboards."*
|
||||||
**Owning component:** KPI History (#26); touches Configuration Database (#17),
|
**Owning component:** KPI History (#26); touches Configuration Database (#17),
|
||||||
|
|||||||
@@ -191,5 +191,5 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-07-19",
|
"lastUpdated": "2026-07-19",
|
||||||
"phase1Status": "COMPLETE - all 15 tasks done, live gate PASS. Branch feat/localdb-phase1 NOT merged/pushed."
|
"phase1Status": "COMPLETE - all 15 tasks done, live gate PASS. MERGED to main as 28ca04d7 ('LocalDb adoption Phase 1 + 2: consolidate the site database, delete the bespoke replicators (#23)') - Phase 1 and Phase 2 landed together in that one merge."
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
{"id": 18, "subject": "Task 18: Two-node convergence suite for the Phase 2 tables", "status": "completed", "classification": "high-risk", "blockedBy": [17], "note": "DEVIATION: landed as a NEW file LocalDbPhase2ConvergenceTests.cs rather than extending Phase 1's LocalDbSitePairConvergenceTests.cs, and drives the REAL SiteStorageService instead of hand-written SQL - possible only post-cutover, and what makes the cascade scenario test the shipped transaction rather than a re-creation of it. Scenario 4 was retargeted onto shared_scripts/external_systems/static_attribute_overrides because the plan's version overlapped LocalDbConfigConvergenceTests' N1 scenario almost exactly; the union-survives property is per-table, so re-proving it on untouched tables is the non-redundant half. Cascade test carries a never-removed control instance (absence assertions otherwise cannot distinguish 'cascade converged' from 'node B lost these tables'). Non-vacuity PROVEN as mandated: with the 8 RegisterReplicated calls commented out, 4 failed / 0 passed; restored, 20/20 across the three LocalDb suites.", "commit": "15013156"},
|
{"id": 18, "subject": "Task 18: Two-node convergence suite for the Phase 2 tables", "status": "completed", "classification": "high-risk", "blockedBy": [17], "note": "DEVIATION: landed as a NEW file LocalDbPhase2ConvergenceTests.cs rather than extending Phase 1's LocalDbSitePairConvergenceTests.cs, and drives the REAL SiteStorageService instead of hand-written SQL - possible only post-cutover, and what makes the cascade scenario test the shipped transaction rather than a re-creation of it. Scenario 4 was retargeted onto shared_scripts/external_systems/static_attribute_overrides because the plan's version overlapped LocalDbConfigConvergenceTests' N1 scenario almost exactly; the union-survives property is per-table, so re-proving it on untouched tables is the non-redundant half. Cascade test carries a never-removed control instance (absence assertions otherwise cannot distinguish 'cascade converged' from 'node B lost these tables'). Non-vacuity PROVEN as mandated: with the 8 RegisterReplicated calls commented out, 4 failed / 0 passed; restored, 20/20 across the three LocalDb suites.", "commit": "15013156"},
|
||||||
{"id": 19, "subject": "Task 19: Rig configuration (MaxOplogRows/MaxOplogAge + MaxBatchSize per D6)", "status": "completed", "classification": "small", "blockedBy": [17], "note": "MaxBatchSize 500->16 (D6: row-count batching x ~70 KB production config_json vs the 4 MB gRPC cap; 16 => ~1.1 MB worst case). MaxOplogRows 1M->250,000 and MaxOplogAge 7d->2d from the soak's 0.80 rows/sec (~69k/day). Tighter-than-default is SAFE because a cap breach prunes + sets needs_snapshot (graceful snapshot resync), not data loss - the Task 1 finding that the plan's stop condition was weaker than written. site-b/site-c left unreplicated so default-OFF stays proven side by side.", "commit": "921edab4"},
|
{"id": 19, "subject": "Task 19: Rig configuration (MaxOplogRows/MaxOplogAge + MaxBatchSize per D6)", "status": "completed", "classification": "small", "blockedBy": [17], "note": "MaxBatchSize 500->16 (D6: row-count batching x ~70 KB production config_json vs the 4 MB gRPC cap; 16 => ~1.1 MB worst case). MaxOplogRows 1M->250,000 and MaxOplogAge 7d->2d from the soak's 0.80 rows/sec (~69k/day). Tighter-than-default is SAFE because a cap breach prunes + sets needs_snapshot (graceful snapshot resync), not data loss - the Task 1 finding that the plan's stop condition was weaker than written. site-b/site-c left unreplicated so default-OFF stays proven side by side.", "commit": "921edab4"},
|
||||||
{"id": 20, "subject": "Task 20: Live gate on the docker rig", "status": "completed", "classification": "high-risk", "blockedBy": [18, 19], "note": "ALL 10 CHECKS PASS. Evidence: docs/plans/2026-07-19-localdb-phase2-live-gate.md. Key blocker found and fixed mid-run: external systems reach a site ONLY via ArtifactDeploymentService, which `instance deploy` never invokes - `deploy artifacts` was needed both to deliver the probe harness and to propagate the owed ExternalSystemDefinitions restore. THREE METHOD CORRECTIONS to the plan: (1) its instruction to run DB checks host-side against the bind mounts is UNSAFE - host sqlite3 poisons the container WAL; copy the db/-wal/-shm triplet and query the copy. (2) `docker exec ... curl` cannot scrape metrics (no curl in aspnet:10.0) and with 2>/dev/null the failure is silent - it nearly became a false 'metrics missing' finding; use a network-sharing curl sidecar. (3) checks needing S&F load need CachedCall, not Call. CAVEATS recorded not glossed: check 2's zero-count is vacuous alone (legacy source was also empty) and rests on the ABSENCE of CDC triggers; check 7's native_alarm_state leg was empty live and is covered only offline; check 10's sampling is coarse and the real rise/drain evidence comes from check 6's 0->4->0.", "commit": "158e79bb"},
|
{"id": 20, "subject": "Task 20: Live gate on the docker rig", "status": "completed", "classification": "high-risk", "blockedBy": [18, 19], "note": "ALL 10 CHECKS PASS. Evidence: docs/plans/2026-07-19-localdb-phase2-live-gate.md. Key blocker found and fixed mid-run: external systems reach a site ONLY via ArtifactDeploymentService, which `instance deploy` never invokes - `deploy artifacts` was needed both to deliver the probe harness and to propagate the owed ExternalSystemDefinitions restore. THREE METHOD CORRECTIONS to the plan: (1) its instruction to run DB checks host-side against the bind mounts is UNSAFE - host sqlite3 poisons the container WAL; copy the db/-wal/-shm triplet and query the copy. (2) `docker exec ... curl` cannot scrape metrics (no curl in aspnet:10.0) and with 2>/dev/null the failure is silent - it nearly became a false 'metrics missing' finding; use a network-sharing curl sidecar. (3) checks needing S&F load need CachedCall, not Call. CAVEATS recorded not glossed: check 2's zero-count is vacuous alone (legacy source was also empty) and rests on the ABSENCE of CDC triggers; check 7's native_alarm_state leg was empty live and is covered only offline; check 10's sampling is coarse and the real rise/drain evidence comes from check 6's 0->4->0.", "commit": "158e79bb"},
|
||||||
{"id": 21, "subject": "Task 21: Documentation truth pass", "status": "completed", "classification": "standard", "blockedBy": [20], "note": "Both CLAUDE.md files + Component-StoreAndForward.md:83 (normative resync paragraph rewritten for CDC, stating the duplicate-delivery bound explicitly: limited to messages the OLD primary delivered whose status change had not yet replicated when the gate flipped - one flush interval plus in-flight ack, and it does NOT grow with backlog depth or absence duration) + components/{StoreAndForward,SiteRuntime,Host}.md + the frame-size known-issue (amended: Phase 2 deleted notify-and-fetch itself, so the 128KB Akka frame constraint is gone from the intra-site hop entirely; successor ceiling is the 4MB gRPC cap via MaxBatchSize, and note the failure mode differs - oversized gRPC is REJECTED, not silently dropped) + deployment topology-guide.md and installation-guide.md (D5 stop-both-together, D2 TombstoneRetention resurrection bound). DoD closed: build 0 warnings, all 10 suites green (3509 tests, 0 failures). DoD grep nuance recorded in the plan: 4 matches remain in src/ and are all deliberate COMMENT prose explaining what was deleted - a literal 'no matches' would delete the explanations that stop someone re-introducing the old design.", "commit": null}
|
{"id": 21, "subject": "Task 21: Documentation truth pass", "status": "completed", "classification": "standard", "blockedBy": [20], "note": "Both CLAUDE.md files + Component-StoreAndForward.md:83 (normative resync paragraph rewritten for CDC, stating the duplicate-delivery bound explicitly: limited to messages the OLD primary delivered whose status change had not yet replicated when the gate flipped - one flush interval plus in-flight ack, and it does NOT grow with backlog depth or absence duration) + components/{StoreAndForward,SiteRuntime,Host}.md + the frame-size known-issue (amended: Phase 2 deleted notify-and-fetch itself, so the 128KB Akka frame constraint is gone from the intra-site hop entirely; successor ceiling is the 4MB gRPC cap via MaxBatchSize, and note the failure mode differs - oversized gRPC is REJECTED, not silently dropped) + deployment topology-guide.md and installation-guide.md (D5 stop-both-together, D2 TombstoneRetention resurrection bound). DoD closed: build 0 warnings, all 10 suites green (3509 tests, 0 failures). DoD grep nuance recorded in the plan: 4 matches remain in src/ and are all deliberate COMMENT prose explaining what was deleted - a literal 'no matches' would delete the explanations that stop someone re-introducing the old design.", "commit": "7b5a5a6f"}
|
||||||
],
|
],
|
||||||
"knownFlakes": [
|
"knownFlakes": [
|
||||||
{
|
{
|
||||||
@@ -102,5 +102,5 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-07-20",
|
"lastUpdated": "2026-07-20",
|
||||||
"phase2Status": "UNBLOCKED - Task 1 gate CLOSED (verdict PROCEED) and Task 2 DONE, 2026-07-20. Task 1's original STOP verdict is SUPERSEDED: the 'Phase 1 disk I/O defect' was OBSERVER-INDUCED (host-side sqlite3 against live bind-mounted WAL files resets the WAL across virtiofs and permanently poisons the container's connections) - NOT a product defect. See docs/known-issues/2026-07-20-localdb-disk-io-error-under-load.md. Both earlier isolation claims were confounded: one sampling pass had already poisoned BOTH nodes, and a poisoned standby looks healthy only because it issues almost no statements; 'LocalDb-specific' was sampling-selection bias (only the LocalDb file had ever been host-read). Clean re-run 2026-07-20 with the copy-based snap() helper on restarted nodes, 6 consecutive 60s intervals: sf_messages 48 rows/min = 0.80 rows/sec dead steady, retry-UPDATE rate 0/sec (SUM(retry_count) flat at 200), oplog 0, native_alarm_state 0, max payload_json 76 B, max config_json 721 B, ZERO SQLite errors across 30 min, both site-a nodes converged at 3564 rows. Honest gap: 0.80/s is ~1.6% of the 50/s ceiling and the retry-UPDATE path was never exercised - acceptable because that ceiling is structural (SweepBatchLimit/RetryTimerInterval), not empirical. Rig config rows (721 B) are NOT representative; D6 sizing rests on the documented ~60-70 KB production config_json. Plan-premise corrections stand: D6 (MaxBatchSize 500 -> 16, the one firmly evidence-backed number; Task 19 sets it), D4 (alarm writes bounded by per-SourceReference coalescing at a 100 ms flush, NOT unbounded), sf_messages hard ceiling 50 rows/sec, oplog cap overrun = graceful snapshot resync (needs_snapshot), not data loss. NEXT: Wave 1 = Tasks 3 + 4, dispatchable in parallel (disjoint Files blocks). Tasks 3-21 untouched; no plan code written yet. Rig cleanup still owed before Task 20: restore ExternalSystemDefinitions id 1 to http://scadabridge-restapi:5200 (currently http://127.0.0.1:9), and remove SoakGenerator template 2021 + instances soakgen-1..4 (ids 5-8), still deployed and generating load."
|
"phase2Status": "COMPLETE - all 21 tasks done, live gate PASS (docs/plans/2026-07-19-localdb-phase2-live-gate.md), MERGED to main as 28ca04d7 ('LocalDb adoption Phase 1 + 2 ... (#23)'), which carried Phase 1 in the same merge. History below retained for context. Task 1 gate CLOSED (verdict PROCEED) and Task 2 DONE, 2026-07-20. Task 1's original STOP verdict is SUPERSEDED: the 'Phase 1 disk I/O defect' was OBSERVER-INDUCED (host-side sqlite3 against live bind-mounted WAL files resets the WAL across virtiofs and permanently poisons the container's connections) - NOT a product defect. See docs/known-issues/2026-07-20-localdb-disk-io-error-under-load.md. Both earlier isolation claims were confounded: one sampling pass had already poisoned BOTH nodes, and a poisoned standby looks healthy only because it issues almost no statements; 'LocalDb-specific' was sampling-selection bias (only the LocalDb file had ever been host-read). Clean re-run 2026-07-20 with the copy-based snap() helper on restarted nodes, 6 consecutive 60s intervals: sf_messages 48 rows/min = 0.80 rows/sec dead steady, retry-UPDATE rate 0/sec (SUM(retry_count) flat at 200), oplog 0, native_alarm_state 0, max payload_json 76 B, max config_json 721 B, ZERO SQLite errors across 30 min, both site-a nodes converged at 3564 rows. Honest gap: 0.80/s is ~1.6% of the 50/s ceiling and the retry-UPDATE path was never exercised - acceptable because that ceiling is structural (SweepBatchLimit/RetryTimerInterval), not empirical. Rig config rows (721 B) are NOT representative; D6 sizing rests on the documented ~60-70 KB production config_json. Plan-premise corrections stand: D6 (MaxBatchSize 500 -> 16, the one firmly evidence-backed number; Task 19 sets it), D4 (alarm writes bounded by per-SourceReference coalescing at a 100 ms flush, NOT unbounded), sf_messages hard ceiling 50 rows/sec, oplog cap overrun = graceful snapshot resync (needs_snapshot), not data loss. Tasks 3-21 were subsequently executed and all landed. Rig cleanup that was owed before Task 20 is DONE: ExternalSystemDefinitions id 1 restored to http://scadabridge-restapi:5200 (propagation to both site nodes confirmed), and SoakGenerator template 2021 + instances soakgen-1..4 deleted - see 'Rig state as left' in the live-gate doc."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,10 +192,20 @@ was down and back to 0 after rejoin.
|
|||||||
`GateDeadTarget` centrally and re-running `deploy artifacts`, both site nodes still carry it.
|
`GateDeadTarget` centrally and re-running `deploy artifacts`, both site nodes still carry it.
|
||||||
Artifact application is an upsert with no reconciliation of removals. Pre-existing behaviour in
|
Artifact application is an upsert with no reconciliation of removals. Pre-existing behaviour in
|
||||||
the artifact pipeline, unrelated to LocalDb — but it means site config tables accumulate orphans.
|
the artifact pipeline, unrelated to LocalDb — but it means site config tables accumulate orphans.
|
||||||
|
**Update 2026-08-07 (truth sweep): RESOLVED** by `2d03f2d5` ("fix(site-runtime): reconcile
|
||||||
|
artifact deletions on apply", 2026-08-01) — the artifact apply is now upsert-then-reconcile:
|
||||||
|
after storing the incoming complete set, `SiteStorageService.DeleteRowsExceptAsync` removes any
|
||||||
|
stored row absent from it, per artifact table, explicitly including **external systems**
|
||||||
|
(`DeleteExternalSystemsExceptAsync` on `external_systems`) plus shared scripts, database
|
||||||
|
connections and data-connection definitions; a null (not-shipped) list still touches nothing,
|
||||||
|
and runtime cleanup rides along (shared-script unregistration, DCL eviction of the removed
|
||||||
|
connection).
|
||||||
- **Deleting an instance orphans its buffered messages.** Removing the `soakgen-*` instances left
|
- **Deleting an instance orphans its buffered messages.** Removing the `soakgen-*` instances left
|
||||||
their 11,804 `sf_messages` with no tracking snapshot, producing a continuous
|
their 11,804 `sf_messages` with no tracking snapshot, producing a continuous
|
||||||
`Cached-telemetry drain: no tracking snapshot for …` warning flood. Also pre-existing, and worth
|
`Cached-telemetry drain: no tracking snapshot for …` warning flood. Also pre-existing, and worth
|
||||||
a cleanup path.
|
a cleanup path. **RESOLVED** by `62cddcfa` ("let an unresolvable cached row leave the drain
|
||||||
|
queue", merged `663d03e8`) — an unresolvable row is now dropped from the drain queue instead of
|
||||||
|
being retried forever, so the hot loop and the warning flood are gone.
|
||||||
|
|
||||||
## Rig state as left
|
## Rig state as left
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,11 @@ Sustained rate observed: ~**2.9 HTTP attempts/sec** (688–864 connection-refuse
|
|||||||
image does not implement. After `reseed.sh` drops the volume (`docker compose down -v`),
|
image does not implement. After `reseed.sh` drops the volume (`docker compose down -v`),
|
||||||
nothing recreates `ScadaBridgeConfig` or the `scadabridge_app` login, so `reseed.sh` hangs
|
nothing recreates `ScadaBridgeConfig` or the `scadabridge_app` login, so `reseed.sh` hangs
|
||||||
forever on its "Waiting for setup.sql to create ScadaBridgeConfig" poll. Worked around by
|
forever on its "Waiting for setup.sql to create ScadaBridgeConfig" poll. Worked around by
|
||||||
applying the three init scripts by hand. **NOT yet fixed in the repo.**
|
applying the three init scripts by hand. **Since FIXED in the repo:** `infra/reseed.sh` now
|
||||||
|
applies all three scripts explicitly via `sqlcmd` (`mssql/setup.sql`,
|
||||||
|
`mssql/machinedata_seed.sql`, `mssql/setup-env2.sql`), with a comment recording that the
|
||||||
|
`mssql/server` image has no initdb hook; the `/docker-entrypoint-initdb.d/` mounts in
|
||||||
|
`infra/docker-compose.yml` are documented as **informational only**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -97,12 +97,17 @@ mis-explanation in the repo ("the alone-oldest is dead and cannot down itself").
|
|||||||
|
|
||||||
## Residual operational notes
|
## Residual operational notes
|
||||||
|
|
||||||
- **Seed-node bootstrap constraint still applies to boot-alone**: only the first seed
|
- ~~**Seed-node bootstrap constraint still applies to boot-alone**~~ — **SUPERSEDED
|
||||||
may self-form a cluster. Auto-down removes the active-crash outage (the survivor never
|
(2026-07-22), no longer a residual.** As written: only the first seed may self-form a
|
||||||
restarts), but a node that must BOOT alone while its peer is dead (cold start of only
|
cluster, so a node that must BOOT alone while its peer is dead (cold start of only the
|
||||||
the non-first-seed VM, or the survivor crashing while the peer is still down) still
|
non-first-seed VM, or the survivor crashing while the peer is still down) waits in
|
||||||
waits in `InitJoin` for its peer. Operator recovery unchanged (restart first seed, or
|
`InitJoin` for its peer. This was **closed** by the self-first seed-node ordering change
|
||||||
self-first seed override).
|
(`docs/plans/2026-07-22-selfform-fallback-and-manual-failover.md`): every node now lists
|
||||||
|
ITSELF first in `seed-nodes`, so any node can cold-start alone and become operational
|
||||||
|
unattended, and `StartupValidator` fails the boot if the ordering is broken — regression
|
||||||
|
coverage in `SelfFirstSeedBootstrapTests`. The residual cost of self-first-on-both (a
|
||||||
|
*truly simultaneous* cold start racing `FirstSeedNodeProcess` on both nodes) is in turn
|
||||||
|
covered by the opt-in `ScadaBridge:Cluster:BootstrapGuard` (Gitea #33, default OFF).
|
||||||
- Monitoring already surfaces dual-active if it ever happens: both nodes report
|
- Monitoring already surfaces dual-active if it ever happens: both nodes report
|
||||||
`IsActive` in heartbeats / both `/health/active` = 200 — the Health dashboard shows
|
`IsActive` in heartbeats / both `/health/active` = 200 — the Health dashboard shows
|
||||||
two Primaries.
|
two Primaries.
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ Server's retrying strategy refuses to run a split query inside a caller's transa
|
|||||||
**bundle import fails against real MS SQL**. The fix is the one the exception names: wrap the
|
**bundle import fails against real MS SQL**. The fix is the one the exception names: wrap the
|
||||||
transaction in `Database.CreateExecutionStrategy().ExecuteAsync(...)`.
|
transaction in `Database.CreateExecutionStrategy().ExecuteAsync(...)`.
|
||||||
|
|
||||||
|
> **FIXED (Gitea #28, commit `c4dcd9bc`)** — bundle import now runs inside the execution
|
||||||
|
> strategy, with a regression test.
|
||||||
|
|
||||||
Why the whole unit/integration suite is green on it: those tests use the in-memory EF provider,
|
Why the whole unit/integration suite is green on it: those tests use the in-memory EF provider,
|
||||||
which has no retrying execution strategy — and `BeginTransactionAsync` is a no-op there. The
|
which has no retrying execution strategy — and `BeginTransactionAsync` is a no-op there. The
|
||||||
comment directly above line 1298 documents that divergence without drawing the conclusion. Only
|
comment directly above line 1298 documents that divergence without drawing the conclusion. Only
|
||||||
@@ -134,13 +137,18 @@ This has been failing since 2026-07-10, and it matters more than a red line: eve
|
|||||||
the toast assertion — including **the secret-non-leak assertion that the Auth Token value never
|
the toast assertion — including **the secret-non-leak assertion that the Auth Token value never
|
||||||
reaches the page HTML** — has not executed since. Fix is a valid 32-hex SID in the fixture.
|
reaches the page HTML** — has not executed since. Fix is a valid 32-hex SID in the fixture.
|
||||||
|
|
||||||
|
> **FIXED (Gitea #29, commit `8524a7f7`)** — the fixture now uses a valid 32-hex Twilio SID, so
|
||||||
|
> the create path (and the secret-non-leak assertion behind it) executes again.
|
||||||
|
|
||||||
### Not covered by this gate
|
### Not covered by this gate
|
||||||
|
|
||||||
- Streaming subscriptions were exercised in-process (TestServer), not over the rig. The
|
- Streaming subscriptions were exercised in-process (TestServer), not over the rig. The
|
||||||
interceptor is path-scoped, not method-scoped, so the rig's `PullAuditEvents` evidence covers
|
interceptor is path-scoped, not method-scoped, so the rig's `PullAuditEvents` evidence covers
|
||||||
the same code path — but a live `SubscribeInstance` under load is untested here.
|
the same code path — but a live `SubscribeInstance` under load is untested here.
|
||||||
- Key **rotation** on a live pair.
|
- Key **rotation** on a live pair.
|
||||||
- `docker-env2` was updated with its own key but not redeployed or gated.
|
- `docker-env2` was updated with its own key but not redeployed or gated. — **CLOSED
|
||||||
|
(Gitea #31, commit `e0f105c3`)**: `docker-env2` was rebuilt on the PSK build and gated,
|
||||||
|
**PASS 3/3**; see the "Env2 gate" section at the end of this document.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -234,3 +242,309 @@ plan-vs-code finding, recorded rather than worked around.
|
|||||||
|
|
||||||
The instance-dependent matrix (tag ops, lifecycle, standby parked retry) and `TriggerSiteFailover`
|
The instance-dependent matrix (tag ops, lifecycle, standby parked retry) and `TriggerSiteFailover`
|
||||||
get their live exercise in Phase 3's full UI command matrix; the transport itself is proven here.
|
get their live exercise in Phase 3's full UI command matrix; the transport itself is proven here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — full site→central cutover + S&F soak — **PASS** (2026-07-23)
|
||||||
|
|
||||||
|
All three sites (**all 6 nodes**) flipped to `CentralTransport=Grpc` (edit to
|
||||||
|
`docker/site-*/appsettings.Site.json`, reverted in git after the gate — defaults stay Akka),
|
||||||
|
rebuilt from `main` + `--force-recreate`. Central left on `SiteTransport=Akka` (Phase 3 owns
|
||||||
|
that direction). The gate was driven by a **live S&F workload**, closing the notification/audit
|
||||||
|
path that 1A/1B could only unit-prove.
|
||||||
|
|
||||||
|
### S&F driver
|
||||||
|
|
||||||
|
A minimal dependency-free template `SoakNotify` (one 5 s `Interval` script:
|
||||||
|
`Notify.To("Engineering Alerts").Send(...)`), 3 instances deployed+enabled on site-a → a steady
|
||||||
|
**3 notifications per 5 s bucket** (the pre-existing `Motor Controller` "soak-motor" instances
|
||||||
|
need 30 OPC UA bindings and were unusable). The central `dbo.Notifications` table (one row per
|
||||||
|
`NotificationId`, insert-if-not-exists) is the no-loss/no-dupes source of truth.
|
||||||
|
|
||||||
|
### Checks
|
||||||
|
|
||||||
|
| # | Check | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | All 6 site nodes on `CentralTransport=Grpc` | **PASS** — each logs `Site→central transport: gRPC to 2 central endpoint(s)`; **0** `PermissionDenied` across all six for the whole run |
|
||||||
|
| 2 | Full control plane rides gRPC `CentralControlService` | **PASS** — central sees `Heartbeat`, `ReportSiteHealth`, `SubmitNotification` (the S&F path), `IngestAuditEvents` — **196 RPCs / 90 s, 0 non-200** |
|
||||||
|
| 3 | Health/heartbeat cadence unchanged, no sequence regressions | **PASS** — `CentralHealthAggregator` logged **0** sequence-regression/out-of-order lines; heartbeat steady at ~144/30 s across 3 sites |
|
||||||
|
| 4 | **Single-node failover** — hard-kill the **active** central (central-a) | **PASS** — `CentralChannelProvider` logged sticky failover `central-a:8083 → central-b:8083` at the instant of kill; central-b active in **29 s** (auto-down); notif count froze at 72 during the gap |
|
||||||
|
| 5 | Buffer drains, no loss/dupes (single-node) | **PASS** — count resumed 72→101; **every 5 s bucket through the outage = exactly 3**, no gap; 101 total == 101 distinct |
|
||||||
|
| 6 | **Failback** — restart central-a | **PASS** — rejoined **ready in ~5 s as standby** (`active=503`); central-b **retained active** (oldest-Up, no role flap); traffic uninterrupted (uniform 3/bucket across failback); central-a singletons → `Younger` |
|
||||||
|
| 7 | **Full central outage** — hard-kill **both** central (~59 s) | **PASS** — count frozen at 155 for the entire outage; sites buffered continuously |
|
||||||
|
| 8 | Cold re-form + drain, no loss/dupes (both-down) | **PASS** — cold cluster re-formed, central-b active in **~14 s**; ~42 buffered notifications drained; **every 5 s bucket across the whole ~59 s both-dead window = exactly 3**, no gap; **216 total == 216 distinct** |
|
||||||
|
|
||||||
|
Checks 4–8 also stand as a live preview of Phase 5 checks 4 (failover/failback) and 5 (mid-drain
|
||||||
|
kill, zero loss/zero dupes).
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **Audit telemetry also rides the new gRPC `CentralControlService`** (`IngestAuditEvents`) — the
|
||||||
|
`SiteAuditTelemetryActor` "ClusterClientSiteAuditClient" label is legacy naming, not the wire
|
||||||
|
path. So `CentralTransport=Grpc` moves heartbeat, health, notification S&F **and** audit off
|
||||||
|
ClusterClient in one flip.
|
||||||
|
- Sites settled on **central-a** as the gRPC endpoint after the cold both-restart while **central-b**
|
||||||
|
held the active/singleton role — the gRPC endpoint node and the singleton host can differ; central-a
|
||||||
|
receives the forward and Akka-routes to the `NotificationOutboxActor` singleton on central-b. Both
|
||||||
|
are correct and independent.
|
||||||
|
- Some notifications land `Parked` at central (no SMTP config on the bare rig) — irrelevant to the
|
||||||
|
transport proof: the `Notifications` **row** is written on forward regardless of downstream SMTP
|
||||||
|
delivery, so the count is a faithful no-loss/no-dupes measure.
|
||||||
|
- Rig left running on the gRPC build; git config reverted to Akka default (a redeploy from `main`
|
||||||
|
resets to all-Akka).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — full central→site cutover + command matrix — **PASS** (2026-07-23)
|
||||||
|
|
||||||
|
Central flipped to `SiteTransport=Grpc` (both central nodes; the flag is **central-wide** —
|
||||||
|
`CentralCommunicationActor.SelectTransport`). Sites kept on `CentralTransport=Grpc` from Phase 2,
|
||||||
|
so the rig ran **both directions on gRPC simultaneously** — the eventual all-gRPC end state.
|
||||||
|
Config bind-mounted (`:ro`), so a container `--force-recreate` (no image rebuild — code unchanged
|
||||||
|
since Phase 2) applied it. Central logged the selection at startup:
|
||||||
|
`central→site command transport: gRPC (SiteCommandService)`.
|
||||||
|
|
||||||
|
The Phase 2 `SoakNotify` instances (#95–97) **survived the recreate as Enabled** (site volume
|
||||||
|
persisted `deployed_configurations` this time), so the notification workload kept flowing —
|
||||||
|
and lifecycle commands became drivable, closing the 1B/Phase-2 gap.
|
||||||
|
|
||||||
|
### Checks
|
||||||
|
|
||||||
|
| # | Check | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Central selects the gRPC command transport | **PASS** — `central→site command transport: gRPC (SiteCommandService)`; **0** `Created ClusterClient to` site lines on either central |
|
||||||
|
| 2 | `ExecuteQuery` (event-log) over gRPC, all 3 sites | **PASS** — all 3 returned correlationIds; site logs `SiteCommandService/ExecuteQuery - 200` (site-a-a/b-a/c-a each served the calls; standby nodes 0 — work lands on the active site node via the singleton proxy) |
|
||||||
|
| 3 | `ExecuteParked` (parked query) over gRPC, all 3 sites | **PASS** — all 3 returned `success` payloads over `SiteCommandService/ExecuteParked` |
|
||||||
|
| 4 | `ExecuteLifecycle` (disable→enable) over gRPC, site-a | **PASS** — `instance disable`/`enable #95` → `success:true`; site served `SiteCommandService/ExecuteLifecycle` (×4), instance state toggled — **NEW live coverage vs 1B/Phase-2 (needed a deployed instance)** |
|
||||||
|
| 5 | **Site-node kill mid-command → clean error, no hang** | **PASS** — killed the **active** site-a node (site-a-a) during a 1 s query loop: the in-flight call returned `TIMEOUT` at **dur=30.1 s = the `QueryTimeout` deadline** — bounded, not an indefinite hang (see note) |
|
||||||
|
| 6 | **Site-pair failover mid-stream** | **PASS** — the very next query (~32 s after kill) and all subsequent ones succeeded automatically via **site-a-b**; `SitePairChannelProvider` failed the gRPC channel NodeA→NodeB and the site singleton migrated; site→central S&F never stopped (notif count climbed 619→715 through the kill, still no dupes) |
|
||||||
|
| 7 | No PSK drift | **PASS** — **0** `PermissionDenied`/`Unauthenticated` across all 8 nodes for the whole run |
|
||||||
|
| 8 | Zero ClusterClient activity on the flipped path | **PASS** — central built no site ClusterClients; command routing is entirely `SiteCommandService` gRPC |
|
||||||
|
|
||||||
|
### Note on check 5 (deadline vs fast-fail)
|
||||||
|
|
||||||
|
The command in flight when site-a-a was **hard-killed** (SIGKILL) waited the full 30 s
|
||||||
|
`QueryTimeout` rather than failing fast on connect-refused: an already-dispatched gRPC call on a
|
||||||
|
dropped connection isn't observed as unsent, so it correctly cannot be auto-retried on the peer
|
||||||
|
node (it might have executed) and returns the deadline error to the caller — exactly the plan's
|
||||||
|
"deadline ≠ retry" rule. The deadline is the backstop; "no hang beyond deadline" is satisfied
|
||||||
|
(30.1 s). Only **provably-unsent** connect failures fail over fast, which is why every *subsequent*
|
||||||
|
call recovered immediately via site-a-b.
|
||||||
|
|
||||||
|
### Not driven on this gate (unchanged from 1B/Phase 2)
|
||||||
|
|
||||||
|
- **`ExecuteOpcUa`** (BrowseNode/ReadTagValues/WriteTag) — needs an OPC-bound deployed instance;
|
||||||
|
the bare rig's only deployable template (`SoakNotify`) has no data connection, and `Motor
|
||||||
|
Controller` needs 30 OPC UA bindings. Unit-proven (dispatcher routing ×28).
|
||||||
|
- **`ExecuteRoute`** (inbound-API → routed site script) — needs an inbound method + routing target
|
||||||
|
the rig lacks.
|
||||||
|
- **`TriggerFailover`** — no CLI verb (UI/management-only), destructive; unit-proven (ack-before-
|
||||||
|
`Leave` ordering tests). The hard-kill in check 6 is the live equivalent of a site-pair failover.
|
||||||
|
|
||||||
|
Rig left running with **both** transports on gRPC; git config reverted to Akka default (a redeploy
|
||||||
|
from `main` resets to all-Akka).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — full eight-check gate on the deletion build — **PASS** (2026-07-23)
|
||||||
|
|
||||||
|
The migration's terminal gate, run on `main` **after Phase 4** (`7fd5cb2b`) — the build where the
|
||||||
|
Akka `ClusterClient`/`ClusterClientReceptionist` transport is **physically deleted**, the
|
||||||
|
`CentralTransport`/`SiteTransport` flags are gone, and gRPC is the *only* site↔central transport
|
||||||
|
(no flags to flip). Rig rebuilt from `main` via `bash docker/deploy.sh`, all 9 containers recreated
|
||||||
|
from the new image; central MS SQL and the S&F driver (`SoakNotify` template #2147 / instances
|
||||||
|
#95–97 / list "Engineering Alerts" #28) persisted from prior phases.
|
||||||
|
|
||||||
|
**Provenance guard:** the pre-existing rig image was built `11:17`, *before* the Phase 4 commit
|
||||||
|
(`12:54`); it still carried ClusterClient code and would have invalidated checks 7–8. Confirmed
|
||||||
|
by rebuild timestamp, then by **0** ClusterClient/receptionist log lines across all 8 nodes on the
|
||||||
|
fresh boot. (The lone `ClusterClientReceptionist` symbol still present is inside
|
||||||
|
`Akka.Cluster.Tools.dll` — the library we deliberately keep for `ClusterSingleton` — not our code.)
|
||||||
|
|
||||||
|
Header names for the negative probes: `authorization: Bearer <psk>` + `x-scadabridge-site: <siteId>`
|
||||||
|
(`ISitePskProvider.cs`). Docker rig PSKs: `dev-grpc-psk-docker-site-{a,b,c}`
|
||||||
|
(`ScadaBridge__Communication__SitePsks__site-*`, central override; sites read their own
|
||||||
|
`GrpcPsk` from mounted `appsettings.Site.json`).
|
||||||
|
|
||||||
|
### Checks
|
||||||
|
|
||||||
|
| # | Check | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **PSK negatives** | **PASS (with a contract clarification)** — see below |
|
||||||
|
| 2 | **Site→central matrix** | **PASS** — notifications, both audit paths, health, heartbeat, reconcile all live over gRPC |
|
||||||
|
| 3 | **Central→site matrix** | **PASS (proportionate)** — `ExecuteQuery`/`ExecuteParked`/`ExecuteLifecycle` live; instance-dependent RPCs carried forward (see below) |
|
||||||
|
| 4 | **Failover / failback** | **PASS** — active-central kill → sticky flip in 1 s, central-b active in 26 s; failback kept central-b active (oldest-Up, no flap) |
|
||||||
|
| 5 | **Mid-drain kill, zero loss/dupes** | **PASS** — buffered notifications flushed; **total == distinct** across the outage |
|
||||||
|
| 6 | **Frame-class retirement (>128 KB)** | **PASS** — a single **297,574-byte** gRPC reply succeeded (Akka's 128 KB frame would have dropped it) |
|
||||||
|
| 7 | **No cross-boundary Akka association** | **PASS** — each Akka cluster's membership is strictly its own pair; **0** cross-boundary association lines both directions |
|
||||||
|
| 8 | **Full-rig restart discipline** | **PASS** — all 9 restarted together came up clean; **0** receptionist/ClusterClient lines on any node; sites reconnected over gRPC; S&F resumed with no dupes |
|
||||||
|
|
||||||
|
### Check 1 — PSK negatives (and the contract clarification)
|
||||||
|
|
||||||
|
Against the two gated services on site-a (`:9023`), using the local proto descriptors
|
||||||
|
(`-proto sitestream.proto` / `site_command.proto`; server reflection is off in this build):
|
||||||
|
|
||||||
|
```
|
||||||
|
SiteStreamService/PullAuditEvents:
|
||||||
|
no credentials -> PermissionDenied "Control plane authentication failed."
|
||||||
|
wrong key (site-b's key) -> PermissionDenied
|
||||||
|
correct key -> success (audit events returned)
|
||||||
|
SiteCommandService/ExecuteQuery:
|
||||||
|
no credentials -> PermissionDenied
|
||||||
|
wrong key (site-b's key) -> PermissionDenied
|
||||||
|
```
|
||||||
|
|
||||||
|
site-a-a's log recorded **exactly 4** control-plane rejections — the four deliberate negative
|
||||||
|
probes above, nothing else.
|
||||||
|
|
||||||
|
**Clarification the gate produced:** the plan listed "missing `x-scadabridge-site` ⇒
|
||||||
|
`PermissionDenied`" as a negative case. It does **not** hold on the *site* side, and that is
|
||||||
|
correct by design. `ControlPlaneAuthInterceptor.Authorize` compares the presented bearer against
|
||||||
|
the node's **own single** `GrpcPsk` (`_options.Value.GrpcPsk`) and never reads
|
||||||
|
`x-scadabridge-site`. That header is a **central-side routing hint** — `SitePskProvider` uses it to
|
||||||
|
pick *which* site's key to expect, because central holds many. A single-key site node needs no such
|
||||||
|
hint. Per-site key isolation — the actual security property — is proven by the **wrong-key**
|
||||||
|
rejection (site-b's key refused at site-a), not by the header. Recorded rather than "fixed": adding
|
||||||
|
a header requirement to the site interceptor would be theatre.
|
||||||
|
|
||||||
|
**LocalDb sync unaffected:** the replicated node (site-a) logged one boot-order
|
||||||
|
`faulted; reconnecting` (peer node-b not yet up), the passive peer accepted the
|
||||||
|
`/localdb_sync.v1.LocalDbSync/Sync` POST 1 s later, and the health report settled to
|
||||||
|
`localDbReplicationConnected:true, localDbOplogBacklog:0`. **0** LocalDb sync auth failures — the
|
||||||
|
control-plane interceptor and the separate `LocalDbSyncAuthInterceptor` don't interfere. site-b/c
|
||||||
|
report `localDbReplicationConnected:false` — the intended rig posture (only site-a replicated).
|
||||||
|
|
||||||
|
### Check 2 — site→central matrix
|
||||||
|
|
||||||
|
- **Notifications e2e:** `dbo.Notifications` climbed continuously at the driver's ~3 rows / 5 s,
|
||||||
|
**total == distinct** at every sample (insert-if-not-exists no-loss/no-dupe truth).
|
||||||
|
- **Both audit paths live over gRPC:** in a 3-minute window, `dbo.AuditLog` carried `node-a`/`node-b`
|
||||||
|
rows (105 each — **site-originated**, forwarded over the gRPC `IngestAuditEvents` RPC) *and*
|
||||||
|
`central-a` rows (420 — central-direct-write from the outbox dispatcher). Row count climbing live.
|
||||||
|
- **Health live per site:** `health summary` shows all three sites `isOnline:true`, sequence numbers
|
||||||
|
advancing, fresh heartbeats; site-a `enabledInstanceCount:3` with a live `Notification` S&F buffer.
|
||||||
|
- **Heartbeat → active flag:** central-a `active=200`, central-b `active=503`; each site's report
|
||||||
|
drives its online flag.
|
||||||
|
- **Reconcile self-heal:** restarting `site-a-a` produced a fresh (17:11:24) `Site→central transport:
|
||||||
|
gRPC to 2 central endpoint(s)` then `Reconcile pass … complete: 0 fetched, 0 failed, 0 orphan(s)`;
|
||||||
|
S&F never dropped across the restart (active node kept emitting), no dupes.
|
||||||
|
- **Not driven (carry-forward, bare rig):** cached-call telemetry (`SoakNotify` only calls
|
||||||
|
`Notify.Send`, no `CachedCall`/`CachedWrite`); a notification reaching **Delivered** rather than
|
||||||
|
`Parked` (no SMTP on the rig — the transport truth is the row-count, per Phase 2).
|
||||||
|
|
||||||
|
### Check 3 — central→site matrix
|
||||||
|
|
||||||
|
`ExecuteQuery` (event-log, all 3 sites → correlationIds + entries), `ExecuteParked` (parked-messages,
|
||||||
|
all 3 sites → empty, no parked ops on a bare rig), and `ExecuteLifecycle` (disable→enable #95 →
|
||||||
|
`success:true` both) all rode `SiteCommandService` gRPC; site active nodes logged them
|
||||||
|
(site-a-a: 8 `ExecuteLifecycle` + 4 `ExecuteParked` + 4 `ExecuteQuery`; site-b-a/c-a:
|
||||||
|
`ExecuteParked` + `ExecuteQuery`). **Carried forward, unchanged from Phase 3** (needs a deployed
|
||||||
|
OPC-bound instance / inbound method / a parked op / the destructive UI-only failover verb the bare
|
||||||
|
rig lacks): `ExecuteOpcUa`, `ExecuteRoute`, standby parked retry/discard, `TriggerFailover`
|
||||||
|
(the hard-kill in Check 4 is its live equivalent). All four are unit-proven (dispatcher routing ×28,
|
||||||
|
ack-before-`Leave` ordering).
|
||||||
|
|
||||||
|
### Checks 4 & 5 — failover / failback / mid-drain kill
|
||||||
|
|
||||||
|
Hard-killed the **active** central (central-a) mid-drain: site-a-b logged the sticky endpoint flip
|
||||||
|
`central-a:8083 → central-b:8083` **1 s** after the kill; central-b reached active in **26 s**
|
||||||
|
(auto-down). The notification count froze during the gap, then **flushed the buffered rows and
|
||||||
|
resumed** at the steady 3 / 5 s. Post-drain **total == distinct (4395 == 4395)** — zero loss, zero
|
||||||
|
duplicates. Failback: restarting central-a, it rejoined and central-b **retained** active
|
||||||
|
(`a=503` standby / `b=200` active) — oldest-Up, no role flap.
|
||||||
|
|
||||||
|
### Check 6 — frame-class retirement
|
||||||
|
|
||||||
|
`PullAuditEvents{since_utc: 2020-01-01, batch_size: 5000}` against the active site node returned a
|
||||||
|
single **297,574-byte** reply successfully. Over Akka's default 128 KB frame
|
||||||
|
(`log-frame-size-exceeding` off) this class of payload was silently dropped and the caller's Ask
|
||||||
|
timed out (`docs/known-issues/2026-06-26-…`). On gRPC (4 MB default cap) it is a normal reply.
|
||||||
|
|
||||||
|
### Check 7 — no cross-boundary Akka association
|
||||||
|
|
||||||
|
Each Akka cluster's membership is strictly its own pair: central `[central-a, central-b]`, site-a
|
||||||
|
`[site-a-a, site-a-b]`, site-c `[site-c-a, site-c-b]` — **no site node ever appears in central's
|
||||||
|
membership and vice versa**. Central's log holds **0** `Association with remote system …site-*`
|
||||||
|
lines and **0** references to any site address; site-a's log holds **0** references to any central
|
||||||
|
address. The two boundaries are wired only by per-pair Akka remoting (8082, internal) and the gRPC
|
||||||
|
control/data planes (8083); there is no Akka association across the site↔central line.
|
||||||
|
|
||||||
|
### Check 8 — full-rig restart discipline
|
||||||
|
|
||||||
|
Restarted all 9 rig containers together (pairs together, honouring the LocalDb-replication constraint).
|
||||||
|
Central re-formed with central-a active; **0** receptionist/ClusterClient lines across all 8 nodes;
|
||||||
|
all three sites logged `Site→central transport: gRPC to 2 central endpoint(s)`; S&F resumed
|
||||||
|
(4407 → 4483, **distinct == total**, no dupes).
|
||||||
|
|
||||||
|
### End state
|
||||||
|
|
||||||
|
The migration is complete and proven on the deletion build. The rig runs the committed all-gRPC
|
||||||
|
default (Phase 4 flipped the shipped appsettings to `CentralGrpcEndpoints` — unlike Phases 2/3, a
|
||||||
|
redeploy from `main` now yields all-gRPC, not all-Akka). No ClusterClient remains anywhere in the
|
||||||
|
running system or the source tree. Deferred, unchanged from earlier phases: the instance-dependent
|
||||||
|
central→site RPC matrix (`ExecuteOpcUa`/`ExecuteRoute`/standby parked retry) and live
|
||||||
|
`TriggerFailover`, all unit-proven; PSK **rotation** on a live pair. `docker-env2` is now gated —
|
||||||
|
see below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Env2 gate — `docker-env2` on the gRPC PSK build — **PASS** (2026-07-23, Gitea #31)
|
||||||
|
|
||||||
|
The secondary Transport-testing topology (`docker-env2/`: 2 central + 1 site-x × 2 nodes, host
|
||||||
|
ports 91XX, shared infra) carried its `GrpcPsk` in config since Phase 0 but had never been
|
||||||
|
redeployed or gated against the migration build. Rebuilt `scadabridge:latest` from `main` @
|
||||||
|
`8524a7f7` (includes the #28 Transport execution-strategy fix) and recreated only the env2
|
||||||
|
containers (`bash docker-env2/deploy.sh`; the primary rig's running image was untouched). Site-x
|
||||||
|
PSK `dev-grpc-psk-docker-env2-site-x` on both site nodes matches central's
|
||||||
|
`ScadaBridge__Communication__SitePsks__site-x`.
|
||||||
|
|
||||||
|
| # | Check | Result |
|
||||||
|
|---|-------|--------|
|
||||||
|
| 1 | Both site nodes boot with the key (StartupValidator passes) | **PASS** |
|
||||||
|
| 2 | Unauthenticated control-plane call ⇒ PermissionDenied; correct key ⇒ success | **PASS** |
|
||||||
|
| 3 | LocalDb unaffected | **PASS** |
|
||||||
|
|
||||||
|
### Check 1 — both site nodes boot
|
||||||
|
|
||||||
|
`scadabridge-env2-site-x-a` and `-b` both reached `Application started` with `Now listening on:
|
||||||
|
http://[::]:8083` (the gRPC control/data listener). `StartupValidator` is fail-closed on a missing
|
||||||
|
`ScadaBridge:Communication:GrpcPsk` — a site node without it refuses to boot — so reaching
|
||||||
|
`Application started` is itself the proof the key is present and valid. Each node also logged a clean
|
||||||
|
`Reconcile pass for site site-x node node-{a,b} complete: 0 fetched, 0 failed, 0 orphan(s)`, i.e.
|
||||||
|
the site→central gRPC path was already carrying traffic.
|
||||||
|
|
||||||
|
### Check 2 — PSK auth on the control plane
|
||||||
|
|
||||||
|
`grpcurl` against the site-hosted `SiteStreamService/PullAuditEvents` on both nodes
|
||||||
|
(`localhost:9123` = site-x-a, `localhost:9124` = site-x-b):
|
||||||
|
|
||||||
|
- **No auth header** ⇒ `PermissionDenied: Control plane authentication failed.`
|
||||||
|
- **Wrong bearer key** (`Bearer WRONG-KEY`, `x-scadabridge-site: site-x`) ⇒ `PermissionDenied`.
|
||||||
|
- **Correct key** (`Bearer dev-grpc-psk-docker-env2-site-x`) ⇒ `{}` (empty audit set — the env has
|
||||||
|
no S&F driver seeded — but **not** a permission error: auth accepted). Identical on both nodes.
|
||||||
|
|
||||||
|
(Same contract clarification as the primary rig: the site interceptor authenticates the node's single
|
||||||
|
`GrpcPsk` and does not require `x-scadabridge-site`; per-site isolation is proven by the wrong-key
|
||||||
|
rejection.)
|
||||||
|
|
||||||
|
### Check 3 — LocalDb unaffected
|
||||||
|
|
||||||
|
Env2 site-x runs LocalDb **local-only** (no `LocalDb:Replication:PeerAddress` configured — the rig's
|
||||||
|
replication posture is proven on the primary `docker/` stack, not here), so "unaffected" means the
|
||||||
|
consolidated site DB stays operational on the gRPC build. Site logs hold **0** LocalDb
|
||||||
|
errors/exceptions and both nodes boot healthy with the LocalDb at `/app/data/site-localdb.db`; the
|
||||||
|
clean reconcile passes above confirm the site DB + site→central path work end to end.
|
||||||
|
|
||||||
|
### Bonus / observations
|
||||||
|
|
||||||
|
- **No real ClusterClient.** Central logs hold **0** ClusterClient/receptionist references; the site
|
||||||
|
logs' only match is the benign string label `client=ClusterClientSiteAuditClient` in a
|
||||||
|
`SiteAuditTelemetryActor created` line (a legacy identifier, **not** an Akka `ClusterClient`) —
|
||||||
|
identical to the primary rig's site nodes. No real receptionist/ClusterClient actor path exists.
|
||||||
|
- **Central registers site-x over gRPC.** `Site site-x registered online via heartbeat` — the
|
||||||
|
site→central gRPC command/control path is live.
|
||||||
|
- **Seed-data note (not a migration issue).** `ScadaBridgeConfig2.dbo.Sites` has **0 rows**, so
|
||||||
|
central logs `Reconcile request from unknown site 'site-x' … replying with empty gap` and evicts
|
||||||
|
it from the health aggregator as "no longer configured." This is a first-time-setup seeding gap
|
||||||
|
(`docker-env2/seed-sites.sh` not yet run on the fresh DB), orthogonal to the transport — which
|
||||||
|
demonstrably carries the heartbeat and reconcile regardless.
|
||||||
|
|||||||
@@ -323,16 +323,17 @@ Critical path ≈ 1B: **~4–6 weeks total**, matching the design estimate.
|
|||||||
- [x] 1B DoD (proportionate): rig central on `SiteTransport=Grpc` proves central→site rides authenticated gRPC `SiteCommandService` for all 3 sites (`ExecuteQuery`/`ExecuteParked` → 200, per-site PSK, 0 auth failures); rebased on 1A. Instance-dependent commands (tag ops/lifecycle/standby parked retry) + `TriggerSiteFailover` deferred to Phase 3 (no deployed instance / destructive UI-only); command-plane coexistence not expressible (`SiteTransport` is central-wide). Gate: `2026-07-22-clusterclient-to-grpc-live-gate.md`
|
- [x] 1B DoD (proportionate): rig central on `SiteTransport=Grpc` proves central→site rides authenticated gRPC `SiteCommandService` for all 3 sites (`ExecuteQuery`/`ExecuteParked` → 200, per-site PSK, 0 auth failures); rebased on 1A. Instance-dependent commands (tag ops/lifecycle/standby parked retry) + `TriggerSiteFailover` deferred to Phase 3 (no deployed instance / destructive UI-only); command-plane coexistence not expressible (`SiteTransport` is central-wide). Gate: `2026-07-22-clusterclient-to-grpc-live-gate.md`
|
||||||
|
|
||||||
**Phase 2 ∥ 3 — cutover + soak**
|
**Phase 2 ∥ 3 — cutover + soak**
|
||||||
- [ ] P2 All sites `CentralTransport=Grpc`; central-kill S&F soak (no loss/dupes), failback observed, health sequences clean
|
- [x] P2 All sites `CentralTransport=Grpc`; central-kill S&F soak (no loss/dupes), failback observed, health sequences clean — **PASS 2026-07-23** (live gate: single-node active-kill failover 29s + full-outage ~59s both-down drain; every 5s bucket = exactly 3 through both outages, 216 total == 216 distinct; 0 auth failures / 0 seq regressions; whole control plane — heartbeat/health/notification/audit — on gRPC `CentralControlService`)
|
||||||
- [ ] P3 Central `SiteTransport=Grpc` all sites; full UI command matrix per site; site-kill mid-command clean; no PSK noise; zero ClusterClient log activity on flipped paths
|
- [x] P3 Central `SiteTransport=Grpc` all sites; full UI command matrix per site; site-kill mid-command clean; no PSK noise; zero ClusterClient log activity on flipped paths — **PASS 2026-07-23** (live gate: `ExecuteQuery`/`ExecuteParked` all 3 sites + `ExecuteLifecycle` disable/enable on site-a, all 200 over `SiteCommandService`; active-site-node hard-kill mid-command → clean `TIMEOUT` at the 30s deadline, next call failed over to site-a-b automatically; 0 PermissionDenied / 0 ClusterClient-to-site on both central. `ExecuteOpcUa`/`ExecuteRoute`/`TriggerFailover` deferred — no OPC-bound instance / no CLI verb, unit-proven) **Update 2026-08-07 (truth sweep): no longer deferred — `ExecuteOpcUa`/`ExecuteRoute`/parked-retry/`TriggerSiteFailover` were ALL LIVE-PROVEN 2026-08-01 (`1c99d6fa`); see the Phase-5 closure note below.**
|
||||||
|
|
||||||
**Phase 4 — deletion**
|
**Phase 4 — deletion**
|
||||||
- [ ] Defaults flip to `Grpc` + soak; then delete Akka transports, ClusterClient creation, `DefaultSiteClientFactory`, receptionist registrations, `CentralContactPoints`, then the flags
|
- [x] gRPC made the only transport (flags DELETED, not flipped — end state is identical) + deleted `AkkaCentralTransport`/`AkkaSiteTransport`, ClusterClient creation (`AkkaHostedService`), `DefaultSiteClientFactory`+`ISiteClientFactory`, both receptionist registrations (`:436`/`:1001`), `CentralContactPoints` + the `CentralTransport`/`SiteTransport` flags + `CentralTransportMode`/`SiteTransportKind` enums. `NoOpCentralTransport` added as the fail-loud null-default; `CentralGrpcEndpoints` now unconditional (StartupValidator requires ≥1 on Site). Kept `Akka.Cluster.Tools` (ClusterSingleton). Rig configs (docker ×6, docker-env2 ×2, Host default, wonder-app-vd03) moved `CentralContactPoints`→`CentralGrpcEndpoints`. **Full solution build 0/0; Communication.Tests 640, Host.Tests + StartupValidator + SiteActorPath green, audit-push integration green** (2026-07-23)
|
||||||
- [ ] Grep-gates pass (`clusterclient|receptionist` → historical docs only; `CentralContactPoints` → empty)
|
- [x] Grep-gates pass (`CentralContactPoints` → only "replaces the former" doc refs + plan trackers; deleted symbols → 0 live refs; remaining `clusterclient` in src = the misleadingly-named `ClusterClientSiteAuditClient` [transport-agnostic, works unchanged] + stale inline doc-comments, noted as follow-up) **Update 2026-08-07 (truth sweep): follow-up DONE — commit `63c16d69` (2026-07-27, "retire ClusterClient naming after the gRPC cutover") renamed it to `SiteCommunicationAuditClient` and rewrote the stale inline comments; grep confirms 0 `ClusterClientSiteAuditClient` references remain in `src/`/`tests/`.**
|
||||||
- [ ] Docs updated: `grpc_streams.md`, `Component-Host.md`, `Component-StoreAndForward.md:137`, known-issues cross-ref
|
- [x] Docs updated: `Component-Communication.md`, `components/Communication.md`, `Component-Host.md`, `Component-StoreAndForward.md`, `topology-guide.md`, `grpc_streams.md`, known-issues frame-size amendment, **CLAUDE.md** transport decisions
|
||||||
|
|
||||||
**Phase 5 — live gate** (record in `2026-07-22-clusterclient-to-grpc-live-gate.md`)
|
**Phase 5 — live gate** (record in `2026-07-22-clusterclient-to-grpc-live-gate.md`) — **PASS 2026-07-23** (deletion build, `main` @ `7fd5cb2b`)
|
||||||
- [ ] 1 PSK negatives · [ ] 2 site→central matrix · [ ] 3 central→site matrix · [ ] 4 failover/failback both directions · [ ] 5 mid-drain kill · [ ] 6 >128 KB frame-class proof · [ ] 7 no cross-boundary Akka association · [ ] 8 full-rig restart clean
|
- [x] 1 PSK negatives · [x] 2 site→central matrix · [x] 3 central→site matrix · [x] 4 failover/failback both directions · [x] 5 mid-drain kill · [x] 6 >128 KB frame-class proof · [x] 7 no cross-boundary Akka association · [x] 8 full-rig restart clean
|
||||||
|
- All 8 PASS. Notables: check 1 clarified the site-side gate uses the node's single `GrpcPsk` and ignores `x-scadabridge-site` (central-side routing hint) — per-site isolation proven by wrong-key reject, not the header; check 6 = a **297,574-byte** single gRPC reply (a payload Akka's 128 KB frame dropped); check 7 = Akka cluster membership strictly pair-only, 0 cross-boundary association; check 8 = full-rig restart, 0 receptionist/ClusterClient lines on any of the 8 nodes. Instance-dependent central→site RPCs (`ExecuteOpcUa`/`ExecuteRoute`/standby parked retry/`TriggerFailover`) carried forward unit-proven — bare rig has no OPC-bound instance / inbound method / parked op / CLI failover verb. **All four LIVE-PROVEN 2026-08-01** (rig session): `ExecuteOpcUa` = `data-connection browse` returned the opc-plc root children on site-a; `ExecuteRoute` = `POST /api/RouteCheckHello` → `Route.To("route-check-1").Call("Hello")` round-tripped `hello-from-site-a`; parked retry = a cached call parked at 50 attempts against a stopped REST API, `cached-call retry` relay unparked it and it delivered HTTP 200 (test artifacts cleaned up after); `TriggerSiteFailover` = Health-dashboard button on site-b → relay accepted, active node CoordinatedShutdown (ClusterLeavingReason, exit 0), standby's singletons Younger→Oldest with no gap, old active auto-rejoined as standby in ~21 s. Nothing remains unit-proven-only on this migration.
|
||||||
|
|
||||||
## Gotchas for the executor (will bite; read twice)
|
## Gotchas for the executor (will bite; read twice)
|
||||||
|
|
||||||
|
|||||||
@@ -132,11 +132,12 @@
|
|||||||
"id": "P1A.DoD",
|
"id": "P1A.DoD",
|
||||||
"phase": "1A",
|
"phase": "1A",
|
||||||
"subject": "1A DoD: rig site-a on Grpc proves all 5 site->central paths while site-b/c stay Akka; PR merged before 1B",
|
"subject": "1A DoD: rig site-a on Grpc proves all 5 site->central paths while site-b/c stay Akka; PR merged before 1B",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"activeForm": "Verifying the 1A DoD",
|
"activeForm": "Verifying the 1A DoD",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T1A.4"
|
"T1A.4"
|
||||||
]
|
],
|
||||||
|
"notes": "PASS - ticked in the plan MD. Rig site-a on CentralTransport=Grpc proved the site->central paths (heartbeat/health/reconcile + Akka coexistence on site-b/c); PR #26 merged as aa60f438. Notification/audit deferred to the Phase 2 soak (no deployed instance at the time). The rig caught and fixed a central :5000 HTTP-drop regression (0e162cb2). Gate: docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T1B.1",
|
"id": "T1B.1",
|
||||||
@@ -187,18 +188,19 @@
|
|||||||
"id": "P1B.DoD",
|
"id": "P1B.DoD",
|
||||||
"phase": "1B",
|
"phase": "1B",
|
||||||
"subject": "1B DoD: rig central on Grpc for site-a proves full command matrix incl. standby parked retry; rebased on 1A; PR merged",
|
"subject": "1B DoD: rig central on Grpc for site-a proves full command matrix incl. standby parked retry; rebased on 1A; PR merged",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"activeForm": "Verifying the 1B DoD",
|
"activeForm": "Verifying the 1B DoD",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T1B.4",
|
"T1B.4",
|
||||||
"P1A.DoD"
|
"P1A.DoD"
|
||||||
]
|
],
|
||||||
|
"notes": "PASS (proportionate) - ticked in the plan MD (2fa5e93c). Rig central on SiteTransport=Grpc proved central->site rides the authenticated SiteCommandService for all 3 sites (ExecuteQuery/ExecuteParked/ExecuteLifecycle -> 200, per-site PSK, 0 auth failures); rebased on 1A. Instance-dependent commands (tag ops/lifecycle/standby parked retry) + TriggerSiteFailover deferred to Phase 3 (no deployed instance / destructive UI-only); command-plane coexistence not expressible because SiteTransport is central-wide. Phases 2-5 completed downstream. Gate: docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "P2",
|
"id": "P2",
|
||||||
"phase": "2",
|
"phase": "2",
|
||||||
"subject": "All sites CentralTransport=Grpc; central-kill S&F soak, failback observed, health sequences clean",
|
"subject": "All sites CentralTransport=Grpc; central-kill S&F soak, failback observed, health sequences clean",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"activeForm": "Running the site->central cutover soak",
|
"activeForm": "Running the site->central cutover soak",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"P1A.DoD"
|
"P1A.DoD"
|
||||||
@@ -208,7 +210,7 @@
|
|||||||
"id": "P3",
|
"id": "P3",
|
||||||
"phase": "3",
|
"phase": "3",
|
||||||
"subject": "Central SiteTransport=Grpc all sites; full UI command matrix; site-kill mid-command clean; zero ClusterClient activity",
|
"subject": "Central SiteTransport=Grpc all sites; full UI command matrix; site-kill mid-command clean; zero ClusterClient activity",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"activeForm": "Running the central->site cutover soak",
|
"activeForm": "Running the central->site cutover soak",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"P1B.DoD"
|
"P1B.DoD"
|
||||||
@@ -218,32 +220,35 @@
|
|||||||
"id": "P4.1",
|
"id": "P4.1",
|
||||||
"phase": "4",
|
"phase": "4",
|
||||||
"subject": "Flip both flag defaults to Grpc + soak; delete Akka transports, ClusterClient creation, DefaultSiteClientFactory, receptionist registrations, CentralContactPoints, then the flags",
|
"subject": "Flip both flag defaults to Grpc + soak; delete Akka transports, ClusterClient creation, DefaultSiteClientFactory, receptionist registrations, CentralContactPoints, then the flags",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"activeForm": "Deleting the ClusterClient transport",
|
"activeForm": "Deleting the ClusterClient transport",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"P2",
|
"P2",
|
||||||
"P3"
|
"P3"
|
||||||
]
|
],
|
||||||
|
"notes": "Branch feat/grpc-phase4-deletion (2026-07-23). Flags DELETED rather than flipped \u2014 end state is gRPC-only, identical. Deleted: AkkaCentralTransport, AkkaSiteTransport, ISiteClientFactory+DefaultSiteClientFactory, CentralCommunicationActor legacy ctor + SelectTransport, ClusterClient creation + both ClusterClientReceptionist.RegisterService in AkkaHostedService, CommunicationOptions.CentralContactPoints + CentralTransport/SiteTransport flags + CentralTransportMode/SiteTransportKind enums, RegisterCentralClient message + receive block, AkkaCentralTransportTests + DefaultSiteClientFactoryTests. Added NoOpCentralTransport (fail-loud null-default so command-dispatch TestKit suites keep constructing without a wired transport; production always injects GrpcCentralTransport). CommunicationOptionsValidator: CentralGrpcEndpoints now unconditional (no-blank-entries, role-agnostic); StartupValidator: Site-only requires \u22651 CentralGrpcEndpoint (fail-fast, mirrors GrpcPsk). Host builds GrpcSiteTransport (central) + GrpcCentralTransport (site) unconditionally. Kept Akka.Cluster.Tools (ClusterSingleton). Test rework: deleted the ClusterClient.Send per-site-routing tests (covered by CentralCommunicationActorTransportTests + GrpcSiteTransport suites), swapped ISiteClientFactory\u2192substitute ISiteCommandTransport across 5 files, converted SiteAuditPushFlow's ClusterClientRelay\u2192BridgeCentralTransport, converted Heartbeat_StampsIsActive to a substitute ICentralTransport, repurposed HealthReportAck no-client test to the NoOp fail-loud path, removed SiteActors_CentralClusterClient_Exists. Rig: docker \u00d76 + docker-env2 \u00d72 + Host appsettings.Site.json + deploy/wonder-app-vd03 moved CentralContactPoints\u2192CentralGrpcEndpoints. Full solution build 0/0; Communication.Tests 640, StartupValidator 59, SiteActorPath 5, audit-push integration 1 all green. DID NOT fold in the dead IntegrationCallRequest deletion (#32) \u2014 SiteEnvelope routing is transport-agnostic so it still compiles; user-owned behavioral decision, left OUT of this PR."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "P4.2",
|
"id": "P4.2",
|
||||||
"phase": "4",
|
"phase": "4",
|
||||||
"subject": "Grep-gates pass + docs updated (grpc_streams.md, Component-Host.md, Component-StoreAndForward.md, known-issues cross-ref)",
|
"subject": "Grep-gates pass + docs updated (grpc_streams.md, Component-Host.md, Component-StoreAndForward.md, known-issues cross-ref)",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"activeForm": "Running the deletion grep-gates and doc sweep",
|
"activeForm": "Running the deletion grep-gates and doc sweep",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"P4.1"
|
"P4.1"
|
||||||
]
|
],
|
||||||
|
"notes": "Grep-gates: CentralContactPoints \u2192 only intentional 'replaces the former' doc refs + plan trackers + stale bin/ artifacts (regenerate on build); deleted symbols (DefaultSiteClientFactory/ISiteClientFactory/RegisterCentralClient/AkkaCentralTransport/AkkaSiteTransport/CentralTransportMode/SiteTransportKind/the flags) \u2192 0 live refs. Residual 'clusterclient' in src = ClusterClientSiteAuditClient (misleadingly named but transport-agnostic \u2014 it Asks SiteCommunicationActor, holds no ClusterClient; works unchanged) + ~25 stale inline XML-doc comments across SiteRuntime/Commons/SiteCallAudit \u2014 NOT scrubbed (out of plan scope, no behavior impact); FOLLOW-UP noted. Docs updated (subagent + me): Component-Communication.md, components/Communication.md, Component-Host.md, Component-StoreAndForward.md, deployment/topology-guide.md, plans/grpc_streams.md (SUPERSEDED note + LoadSiteAddressesFromDb correction), known-issues/2026-06-26-deploy-config-exceeds-akka-frame-size.md (2026-07-23 frame-size-retired amendment), and CLAUDE.md (2 transport-decision passages). Phase 5 (live gate) is next and requires a rig redeploy."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "P5",
|
"id": "P5",
|
||||||
"phase": "5",
|
"phase": "5",
|
||||||
"subject": "Live gate, 8 checks, recorded in docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md",
|
"subject": "Live gate, 8 checks, recorded in docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"activeForm": "Running the live gate",
|
"activeForm": "Running the live gate",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"P4.2"
|
"P4.2"
|
||||||
]
|
],
|
||||||
|
"notes": "Live gate PASS 2026-07-23 on Phase 4 deletion build (main @ 7fd5cb2b). All 8 checks PASS: PSK negatives (site-header contract clarified \u2014 site node has one key, header is central-side routing hint; per-site isolation proven by wrong-key reject); site->central matrix (notif no-loss/dupe, both audit paths node-a/b via IngestAuditEvents + central-a direct, health/heartbeat/active, reconcile self-heal over gRPC); central->site matrix ExecuteQuery/Parked/Lifecycle (OpcUa/Route/standby-parked/TriggerFailover carried forward, unit-proven); active-central kill sticky-flip 1s + central-b active 26s; mid-drain total==distinct zero loss/dupes; 297574-byte gRPC reply (frame-class retired); 0 cross-boundary Akka association (cluster membership pair-only); full-rig restart 0 clusterclient lines all 8 nodes. Recorded in docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# OtOpcUa v3 native-alarm B/C live-gate (Gitea #14) — PASS
|
||||||
|
|
||||||
|
**Date:** 2026-07-23 · **Scope:** items **B** (native-alarm dedup) + **C** (UNS-bound alarm routing) of #14,
|
||||||
|
against a UNS structure + scripted Part-9 alarm authored on the live `otopcua-dev` v3 cluster (see
|
||||||
|
`2026-07-23-otopcua-v3-raw-path-live-gate.md` for the connection + `SiteAOnly` substrate). Instrument:
|
||||||
|
ScadaBridge itself (its DCL A&C client is the thing under test).
|
||||||
|
|
||||||
|
## Headline
|
||||||
|
|
||||||
|
**Both B and C PASS, and item C's original premise turned out to be already-solved code.** No ScadaBridge
|
||||||
|
code change is needed for B/C. The scope-doc worry — "a UNS-bound source won't `StartsWith`-match a
|
||||||
|
condition whose `SourceName` is the RawPath/ScriptedAlarmId" — is **obsolete**: ScadaBridge routes native
|
||||||
|
alarms by the **subscribed node reference**, not the event `SourceName` (deliberate fix, Gitea #17). The
|
||||||
|
only remaining #14 work is the item-A data re-author (greenfield, operational).
|
||||||
|
|
||||||
|
## Substrate (on OtOpcUa, SITE-A cluster)
|
||||||
|
|
||||||
|
- UNS: `area-1/line-1/pump01` → signal `SiteAOnly` (equipment folder node `ns=3;s=EQ-54f7711e160d`,
|
||||||
|
which has **`EventNotifier=1`**; signal `ns=3;s=area-1/line-1/pump01/SiteAOnly` mirrors the raw value).
|
||||||
|
- Scripted Part-9 condition `ns=3;s=SA-siteahigh` (a `HasComponent` child of the equipment folder),
|
||||||
|
`AlarmConditionType`, severity 700, predicate driven by `SiteAOnly` (set always-active for the gate).
|
||||||
|
- `Server` (i=2253) `EventNotifier=1` (the connection-wide aggregate).
|
||||||
|
|
||||||
|
## What ScadaBridge did (bindings on template 2148, instance 98, connection `OtOpcUa-v3-raw`)
|
||||||
|
|
||||||
|
Two `TemplateNativeAlarmSource` bindings, deliberately overlapping to stress dedup:
|
||||||
|
- `ServerAlarms` → `--source-ref i=2253` (Server aggregate)
|
||||||
|
- `UnsEquipAlarms` → `--source-ref nsu=https://zb.com/otopcua/uns;s=EQ-54f7711e160d` (UNS equipment node)
|
||||||
|
|
||||||
|
## Findings / checks
|
||||||
|
|
||||||
|
| # | Check | Result |
|
||||||
|
|---|-------|--------|
|
||||||
|
| C1 | A **UNS-node-scoped** binding receives + routes the native alarm | **PASS** — `UnsEquipAlarms` mirrored `SA-siteahigh.SiteA High` active=True, sev 700, `kind=NativeOpcUa` |
|
||||||
|
| C2 | The condition's `SourceName` = **ScriptedAlarmId** (`SA-siteahigh`), NOT RawPath | **Confirmed** (captured live) — and it does **not** break routing (routing keys off the subscribed node, Gitea #17), only feeds the alarm's identity (`alarmName = SA-siteahigh.SiteA High`) |
|
||||||
|
| C3 | Item C's original "SourceName mismatch drops UNS-bound transitions" premise | **Obsolete** — `OpcUaAlarmMapper.BuildIdentity` stamps `SourceObjectReference` = the subscription ref; `DataConnectionActor` routes on that, not `SourceName` |
|
||||||
|
| B1 | Fanned condition (Server aggregate + equipment-folder notifier) mirrored **once**, not duplicated | **PASS** — active condition appears exactly once (on the more-specific equipment feed); the Server binding stays an inactive placeholder. Stable across repeated snapshots |
|
||||||
|
| B2 | Mechanism | ScadaBridge opens **one** OPC UA `Subscription` per connection with one `MonitoredItem` per source-ref; `ConditionRefresh` delivers each retained condition **once per subscription**, to the most-specific monitored notifier (the equipment folder) — so overlapping bindings do not double-mirror the fan-out |
|
||||||
|
| B3 | Residual double-mirror risk | Latent, **not triggered** here: it needs two bindings whose `SourceReference` strings are literal prefixes of one another AND a delivery matching both (`DataConnectionActor` has no cross-binding, per-ConditionId dedup — `InstanceActor._latestAlarmEvents` is last-writer-wins by `alarmName`). Disjoint node refs (as here) don't collide |
|
||||||
|
|
||||||
|
## Captured real payload (what §5 phase 3 wanted)
|
||||||
|
|
||||||
|
`kind=NativeOpcUa`, `alarmName='SA-siteahigh.SiteA High'` (`SourceName`=`SA-siteahigh` = ScriptedAlarmId,
|
||||||
|
`.` + `ConditionName`=`SiteA High`), `alarmTypeName='AlarmConditionType'`, `severity=700`,
|
||||||
|
`condition.active=True`. A&C state is delivered via events/`ConditionRefresh` (ScadaBridge triggers a
|
||||||
|
refresh on every subscribe), not a plain attribute read.
|
||||||
|
|
||||||
|
## Bottom line for #14
|
||||||
|
|
||||||
|
- **A** — raw/UNS binding re-author: validated end-to-end (raw-path gate proved `nsu=` bind→read; this gate
|
||||||
|
proved UNS bind→alarm). Remaining work is data re-authoring (greenfield), **no code**.
|
||||||
|
- **B** — dedup: **PASS**, no double-mirror across the v3 fan-out (one subscription + `ConditionRefresh`).
|
||||||
|
- **C** — UNS-bound routing: **PASS**; the original concern was already solved by Gitea #17.
|
||||||
|
- **D/E/F** — shipped in phase 2 (`2026-07-23-otopcua-v3-nsu-hardening-and-browse-ux.md`).
|
||||||
|
|
||||||
|
**#14 needs no further ScadaBridge code for B/C.** The only shipped code from this whole effort was the
|
||||||
|
phase-2 `nsu=` hardening + the `RealOpcUaClient` host-rewrite fix (surfaced by the raw gate). #14 can close
|
||||||
|
once item A's re-author is done in the target environment(s).
|
||||||
|
|
||||||
|
## Rig artifacts (removable on next `docker/deploy.sh`)
|
||||||
|
|
||||||
|
OtOpcUa ConfigDb: `UnsArea SITEA-area1` / `UnsLine SITEA-line1` / `Equipment EQ-54f7711e160d` /
|
||||||
|
`UnsTagReference UTR-siteapump01-siteaonly` / `Script SC-siteahigh` / `ScriptedAlarm SA-siteahigh`.
|
||||||
|
ScadaBridge: template 2148 native-alarm-sources `ServerAlarms` + `UnsEquipAlarms`; instance 98; connection 3044.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# OtOpcUa v3.0 dual-namespace cutover — scoping (Gitea #14)
|
||||||
|
|
||||||
|
**Date:** 2026-07-23 · **Status:** **DECIDED — D-1/D-2/D-3 all resolved inline in §6; phase 2
|
||||||
|
(`nsu=` hardening + browse UX, items D/E/F) SHIPPED** (`0eb44314`, `97afa84f`, `e04c2617`; this
|
||||||
|
scope doc + the phase-2 plan committed as `266f001a`). Live gates PASS: raw-path
|
||||||
|
`2026-07-23-otopcua-v3-raw-path-live-gate.md`, alarms
|
||||||
|
`2026-07-23-otopcua-v3-alarm-bc-live-gate.md`. Only **item A** (re-author legacy `ns=2`
|
||||||
|
bindings — data/operational, greenfield) remains open. · **Tracked:** Gitea
|
||||||
|
[#14](https://gitea.dohertylan.com/dohertj2/ScadaBridge/issues/14) (CLOSED 2026-07-24) · **Area:**
|
||||||
|
Data Connection Layer (OPC UA adapter) · **Upstream:** OtOpcUa v3.0 (merged `master` 2026-07-16,
|
||||||
|
PR #472, merge `ec6598ce`); design
|
||||||
|
`~/Desktop/OtOpcUa/docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md`.
|
||||||
|
|
||||||
|
## 1. Headline
|
||||||
|
|
||||||
|
**ScadaBridge is already structurally v3-safe.** A prior refactor made the OPC UA reference a
|
||||||
|
**namespace-URI-durable** string resolved dynamically against the live server `NamespaceArray`, so
|
||||||
|
the retirement of OtOpcUa's `EquipmentNodeIds` scheme and the split into two namespaces does **not**
|
||||||
|
break any parsing or binding logic. There is **no hardcoded `otopcua` namespace URI anywhere in
|
||||||
|
`src/`** (only test fixtures), and **nothing in ScadaBridge parses the `{EquipmentId}/…` NodeId
|
||||||
|
shape** — the identifier is opaque.
|
||||||
|
|
||||||
|
The genuine cutover is therefore **narrow**: one **data** problem (a namespace-index collision on
|
||||||
|
legacy bindings), one real **code gap** (native-alarm dedup across the raw+uns notifier fan-out),
|
||||||
|
and some **UX / validation** polish. Most of it can only be *closed* against a re-seeded live v3
|
||||||
|
rig, so this is as much a live-gate as a code change.
|
||||||
|
|
||||||
|
## 2. The v3 wire contract (what changed upstream)
|
||||||
|
|
||||||
|
OtOpcUa replaced its single custom namespace `https://zb.com/otopcua/ns` with **two**:
|
||||||
|
|
||||||
|
| Subtree | Namespace URI | NodeId form | Shape |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Raw** (device tree, source of truth) | `https://zb.com/otopcua/raw` | `ns=<raw>;s=<RawPath>` | `Folder/…/Driver/Device/TagGroup/…/Tag` |
|
||||||
|
| **UNS** (equipment projection) | `https://zb.com/otopcua/uns` | `ns=<uns>;s=<Area>/<Line>/<Equipment>/<EffectiveName>` | Area→Line→Equipment→signal |
|
||||||
|
|
||||||
|
Semantics ScadaBridge can rely on (from the v3 design doc):
|
||||||
|
- Every value has **one source** (the raw tag), fanned to both the raw NodeId and each referencing
|
||||||
|
UNS NodeId with **identical value/quality/timestamp**. Each UNS variable `Organizes`-references
|
||||||
|
its raw node (cross-tree link is browseable).
|
||||||
|
- **Writes** route through **either** NodeId (same `WriteOperate` gating).
|
||||||
|
- **HistoryRead** works via **both** NodeIds under one historian tagname.
|
||||||
|
- **Native Part 9 alarms:** one condition instance at the raw tag, `ConditionId`/primary identity =
|
||||||
|
**RawPath**, fanned by a **single `ReportEvent`** to the raw device folder **and** every
|
||||||
|
referencing equipment folder. A Server-object subscriber gets **exactly one** copy (the SDK
|
||||||
|
dedups on the shared `InstanceStateSnapshot`); folder-scoped subscribers in each namespace see the
|
||||||
|
condition's events.
|
||||||
|
- The old `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) scheme is **retired**.
|
||||||
|
|
||||||
|
OtOpcUa's own "Cross-repo impact" note sized this for us: **every ScadaBridge binding re-binds**;
|
||||||
|
it's a *data* migration, not a code rewrite; the fragile part is that stored refs hard-code the
|
||||||
|
**namespace index** and ScadaBridge stores no URI; the recommended remediation is to **move to
|
||||||
|
`nsu=`-qualified references** while re-binding.
|
||||||
|
|
||||||
|
## 3. Current-state assessment (what is already v3-safe)
|
||||||
|
|
||||||
|
Grounded in the DCL code (`src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/`):
|
||||||
|
|
||||||
|
- **`Adapters/OpcUaNodeReference.cs` — the single translation seam, already URI-durable.**
|
||||||
|
`Resolve` maps `nsu=<uri>` to the live index via `ExpandedNodeId.ToNodeId(expanded,
|
||||||
|
namespaceUris)` against the session's `NamespaceTable`, and **throws** when a URI is absent from
|
||||||
|
the server's `NamespaceArray` (no silent stale-index binding); rejects `svr=` cross-server refs.
|
||||||
|
`ToDurable` emits the `nsu=<uri>` form by reading `namespaceUris.GetString(NamespaceIndex)`.
|
||||||
|
**No change needed — this seam is the reason the cutover is low-risk.**
|
||||||
|
- **`Adapters/RealOpcUaClient.cs`** — every read/write/subscribe/browse routes NodeIds through the
|
||||||
|
seam with the live `_session.NamespaceUris`. No hardcoded URI, no assumed index.
|
||||||
|
- **`Adapters/OpcUaDataConnection.cs`** — passes the configured tag-path string straight to the
|
||||||
|
client; no path parsing/joining. There is **no browse-path→NodeId translation** anywhere
|
||||||
|
(bindings are absolute NodeIds), so there is no indirection layer to update.
|
||||||
|
- **`Commons/Types/DataConnections/OpcUaEndpointConfig.cs`** — carries only `EndpointUrl` + timing/
|
||||||
|
auth/heartbeat; **no namespace URI, index, or NodeId field**. Namespaces are discovered from the
|
||||||
|
live server, not configured — inherently v3-tolerant. Nothing to change here.
|
||||||
|
- **`Adapters/OpcUaAlarmMapper.cs` `BuildIdentity`** — the per-condition key is already
|
||||||
|
`SourceName (= RawPath post-v3) + "." + ConditionName`, i.e. **already aligned** with v3 keying on
|
||||||
|
RawPath; the routing identity is the binding string verbatim (namespace-form-independent).
|
||||||
|
|
||||||
|
## 4. The genuine cutover work
|
||||||
|
|
||||||
|
| # | Item | Kind | Primary files | Confidence |
|
||||||
|
|---|------|------|---------------|------------|
|
||||||
|
| A | **Legacy `ns=<index>` bindings collide with v3.** v2's sole custom namespace and v3's `raw` both land at `ns=2`, so a stored `ns=2;s=…` resolves **without error** but now means a raw-tree node. Every stored binding must be re-authored (or migrated) to the new address space, ideally in `nsu=` form. | **Data** | `TemplateAttribute.DataSourceReference`, `InstanceConnectionBinding.DataSourceReferenceOverride` (config DB); heartbeat `TagPath`; Transport bundles | High (inspection) |
|
||||||
|
| B | **Native-alarm dedup across the raw+uns fan-out.** `RealOpcUaClient.HandleAlarmEvent` keys on RawPath+ConditionName with **no `ConditionId`-based dedup**; it assumes one condition arrives on one feed. If a v3 server delivers the same condition through two notifier paths into the same feed, two `EventFieldList`s each call `onTransition`. Must verify the Server-object aggregate feed collapses to one copy (the v3 doc says the SDK dedups Server-object subscribers, so this *should* hold — but it is the biggest genuine gap and needs a live check; add ConditionId-based dedup only if the live server double-delivers). | **Code + live validation** | `Adapters/RealOpcUaClient.cs` (HandleAlarmEvent), `Adapters/OpcUaAlarmMapper.cs` | Medium (needs live v3) |
|
||||||
|
| C | **~~Alarm routing for UNS-bound sources~~ — OBSOLETE (2026-07-23); no code change needed.** The premise below is superseded by Gitea #17: `OpcUaAlarmMapper.BuildIdentity` stamps `SourceObjectReference` = the **subscribed node reference**, and `DataConnectionActor` routes on *that*, not on the event `SourceName`. Confirmed live — alarm B/C live gate **PASS** 2026-07-23 (`2026-07-23-otopcua-v3-alarm-bc-live-gate.md`): a UNS-node-scoped binding received and routed the condition correctly, and `SourceName` turned out to be the ScriptedAlarmId (not the RawPath) without breaking anything. *Original scoping text:* **Alarm routing for UNS-bound sources (confirmed by D-1 = ingest both).** `DataConnectionActor` routes transitions by `SourceReference.StartsWith(bindingRef)`, but a v3 condition's `SourceName` is the **RawPath** regardless of subtree — so a **UNS-bound** alarm source won't `StartsWith`-match. Needs a routing change: resolve a UNS-bound source to its backing RawPath (via the browseable UNS→Raw `Organizes` reference) or map identities. Confirmed scope; validate the exact `SourceName`/`SourceNode` payload on the live rig before finalizing the mapping. | **Code + live validation** | `Actors/DataConnectionActor.cs` (alarm routing), `Adapters/RealOpcUaClient.cs` | Medium (needs live v3) |
|
||||||
|
| D | **Browse / search UX now shows two subtrees.** `BrowseChildrenAsync` / `AddressSpaceSearch.SearchAsync` start at the standard Objects root and will enumerate **both** raw and uns as sibling roots. Functional, not breaking — but the picker shows two roots, the manual-entry placeholder still nudges `ns=2;s=…`, and the deeper raw hierarchy stresses `VisitedNodeCeiling`/`maxDepth`. | **UX** | `CentralUI/Components/Dialogs/NodeBrowserDialog.razor`, `IOpcUaClient` search caps | High (inspection) |
|
||||||
|
| E | **`nsu=` hardening (optional, recommended).** The picker already emits `nsu=` via `ToDurable`, but the seam still **accepts** bare `ns=<index>`. Decide whether to warn/reject bare `ns=` on save (closes A permanently) or leave it permissive. | **Code** | `OpcUaNodeReference`, binding validation, picker | High (inspection) |
|
||||||
|
| F | **Doc drift.** `ns=` examples in code comments + `Component-DataConnectionLayer.md`; update to `nsu=`. Update the scadaproj umbrella index (cutover step 4). | **Docs** | doc comments, `docs/requirements/Component-DataConnectionLayer.md`, `../scadaproj/CLAUDE.md` | High |
|
||||||
|
|
||||||
|
## 5. Proposed phasing
|
||||||
|
|
||||||
|
1. **Decisions** (§6) — resolve D-1..D-3 before code.
|
||||||
|
2. **`nsu=` hardening + UX** (items D, E, F) — picker emits/enforces `nsu=`, placeholder + search
|
||||||
|
caps updated, docs swept. Pure ScadaBridge-side, unit-testable, no live server required.
|
||||||
|
3. **Re-seed a v3 rig** — bring up an OtOpcUa v3.0 server (docker) and re-author a representative
|
||||||
|
set of bindings across **both** subtrees via the picker (D-1); confirm subscribe/read/write
|
||||||
|
round-trip and HistoryRead via each subtree, and capture the exact native-alarm `SourceName`/
|
||||||
|
`SourceNode` payload so item C's UNS→Raw routing mapping is built against real data.
|
||||||
|
4. **Alarm live-gate** (items B, C) — against the v3 rig, drive a native condition that fans to both
|
||||||
|
raw + equipment notifiers and confirm ScadaBridge sees **exactly one** transition per state
|
||||||
|
change, correctly routed to the bound source. Add ConditionId-based dedup / routing fix **only if
|
||||||
|
the live server double-delivers**.
|
||||||
|
5. **Data migration decision** (item A) — either force re-authoring (greenfield, no automatic map)
|
||||||
|
or write a one-shot migration if a deterministic old→new mapping exists for the rig's data.
|
||||||
|
6. **Umbrella index + issue close.**
|
||||||
|
|
||||||
|
## 6. Decisions needed
|
||||||
|
|
||||||
|
- **D-1 — Which subtree does ScadaBridge ingest? → DECIDED (2026-07-23): BOTH.** The picker
|
||||||
|
surfaces both the Raw (`…/raw`, device tree) and UNS (`…/uns`, Area→Line→Equipment) subtrees, and
|
||||||
|
an operator binds each attribute / native-alarm source against whichever fits — UNS for
|
||||||
|
equipment-modelled signals, Raw where device-level identity is wanted. For **values** this is
|
||||||
|
free: a given ScadaBridge attribute binds to one NodeId, and v3 guarantees identical
|
||||||
|
value/quality/timestamp on both trees, so no per-value dedup arises.
|
||||||
|
**Consequence for alarms (promotes item C from "validate" to "fix"):** a native alarm condition is
|
||||||
|
keyed by its **RawPath** and its `SourceName` is the RawPath *regardless of which subtree the
|
||||||
|
source was bound on*. So a **UNS-bound** alarm source (prefix `nsu=…/uns;s=<Area>/<Line>/…`) will
|
||||||
|
**not** `StartsWith`-match a condition whose `SourceName` is the raw path. Ingesting both therefore
|
||||||
|
**requires** a routing change so a UNS-bound source resolves to the RawPath of its backing raw node
|
||||||
|
(the UNS→Raw `Organizes` reference is browseable and is the link to follow), or an equivalent
|
||||||
|
identity mapping. This is confirmed scope, gated on the live v3 rig (§5 phase 4).
|
||||||
|
- **D-2 — Enforce `nsu=` on save?** *Recommendation: yes* — warn (or reject) bare `ns=<index>`
|
||||||
|
bindings at authoring time so the index-collision class (item A) can never recur. Low effort, high
|
||||||
|
durability.
|
||||||
|
- **D-3 — Migrate legacy bindings, or re-author?** Upstream is greenfield (no reliable automatic
|
||||||
|
old→new mapping), so *re-authoring via the picker is the default*. A migration is only worth
|
||||||
|
writing if this environment has a deterministic mapping worth automating.
|
||||||
|
|
||||||
|
## 7. Risks / what needs a live v3 server
|
||||||
|
|
||||||
|
- Items **B** and **C** (alarm dedup + routing) **cannot be fully closed by code inspection** — they
|
||||||
|
depend on how a live v3 server actually delivers a fanned condition to an aggregate vs
|
||||||
|
folder-scoped subscription. The plan gates them behind a re-seeded v3 rig.
|
||||||
|
- No production config data is preserved upstream (greenfield), so there is **no migration
|
||||||
|
correctness risk** to legacy production rows — only the dev/test rigs need re-authoring.
|
||||||
|
- The `OpcUaEndpointConfig` has no namespace field, so **no schema/EF change** is anticipated; this
|
||||||
|
cutover is expected to add **no EF migration** (to be confirmed once D-2's validation surface is
|
||||||
|
decided).
|
||||||
|
|
||||||
|
## 8. Bottom line
|
||||||
|
|
||||||
|
The prior namespace-durability refactor did the heavy lifting: ScadaBridge already resolves
|
||||||
|
`nsu=`-qualified references against the live `NamespaceArray` and never assumes an index or a NodeId
|
||||||
|
shape. What remains is (1) getting existing bindings onto the new address space (data / re-author),
|
||||||
|
(2) optionally enforcing `nsu=` so the index-collision class is closed for good, (3) tidying the
|
||||||
|
two-subtree browse UX, and (4) a **live alarm-fan-out validation** that is the only item carrying
|
||||||
|
real code risk. Once §6 is decided, phase 2 (hardening + UX + docs) is straightforward ScadaBridge
|
||||||
|
work; phases 3–4 need a re-seeded v3 rig.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user