{
"planPath": "docs/plans/2026-09-28-site-secrets-artifact-deploy.md",
"tasks": [
{
"id": 1,
"subject": "Task 1: Secrets library 0.6.3 \u2014 hub sweep logging + status",
"status": "pending",
"blockedBy": [],
"description": "**Goal:** Every hub sweep outcome is observable (Debug in-sync + hourly Information heartbeat + recovery line) and a public `ISecretsHubSyncStatus` exposes last sweep/success/failures; published as 0.6.3 and pinned in ScadaBridge.\n\n**Files:**\n- Modify: `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/GrpcSecretSyncService.cs`\n- Create: `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/ISecretsHubSyncStatus.cs`\n- Modify: `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/SecretsGrpcHubClientOptions.cs` (add `InSyncLogInterval`, default 1 h, validated > 0)\n- Modify: the Grpc hub client DI extension (register `ISecretsHubSyncStatus` singleton)\n- Modify: `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/Directory.Build.props` (`0.6.3`), `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/CLAUDE.md` (version narrative)\n- Test: `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/GrpcSecretSyncServiceLoggingTests.cs` (new)\n- Modify: `Directory.Packages.props` (ScadaBridge \u2014 all `ZB.MOM.WW.Secrets*` to 0.6.3)\n\n**Acceptance Criteria:**\n- [ ] An in-sync sweep (0 pulled) logs EventId 4 at Debug every time and EventId 5 `Secrets hub in sync ({Count} secret(s)); last change pulled {LastPullUtc}` at Information at most once per `InSyncLogInterval`.\n- [ ] The first successful sweep after \u2265 1 failure logs EventId 6 `Secrets hub sync recovered after {Failures} failed sweep(s)` at Information.\n- [ ] `ISecretsHubSyncStatus` returns `LastSweepUtc`, `LastSuccessUtc`, `ConsecutiveFailures`, `LastError` (message only, never a secret value) reflecting the last sweep.\n- [ ] Existing EventIds 1\u20133 unchanged; library tests green; package 0.6.3 on the Gitea feed; ScadaBridge builds on 0.6.3.\n\n**Verify:** `cd ~/Desktop/scadaproj/ZB.MOM.WW.Secrets && dotnet test --filter FullyQualifiedName~GrpcSecretSyncServiceLoggingTests` \u2192 all pass; `cd ~/Desktop/ScadaBridge && dotnet build ZB.MOM.WW.ScadaBridge.slnx` \u2192 0 errors.\n\n```json:metadata\n{\"files\": [\"~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/GrpcSecretSyncService.cs\", \"~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/ISecretsHubSyncStatus.cs\", \"Directory.Packages.props\"], \"verifyCommand\": \"cd ~/Desktop/scadaproj/ZB.MOM.WW.Secrets && dotnet test --filter FullyQualifiedName~GrpcSecretSyncServiceLoggingTests\", \"acceptanceCriteria\": [\"EventId 4 Debug per in-sync sweep, EventId 5 Information at most once per InSyncLogInterval\", \"EventId 6 recovered line after failures\", \"ISecretsHubSyncStatus reflects last sweep\", \"0.6.3 published and pinned; ScadaBridge builds\"], \"modelTier\": \"standard\"}\n```"
},
{
"id": 2,
"subject": "Task 2: Wire contract \u2014 `SecretArtifact`, `Secrets`, `Secret",
"status": "pending",
"blockedBy": [],
"description": "**Goal:** `DeployArtifactsCommand` carries an optional ciphertext secret list and `ArtifactDeploymentResponse` an optional applied count, end to end over gRPC, backward compatible.\n\n**Files:**\n- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/SecretArtifact.cs`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/DeployArtifactsCommand.cs`, `ArtifactDeploymentResponse.cs`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Protos/site_command.proto` (fields per Global Constraints), vendored `src/ZB.MOM.WW.ScadaBridge.Communication/SiteCommandGrpc/*` via `docker/regen-proto.sh sitecommand`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteCommandDtoMapper.cs` (`ToProto`/`FromProto` for command and response)\n- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteCommandDtoMapperSecretsTests.cs` (new)\n\n**Acceptance Criteria:**\n- [ ] Command round-trip preserves every `SecretArtifact` field byte-for-byte; `Secrets == null` round-trips as null (field absent), empty list as empty.\n- [ ] Response round-trip: `SecretsApplied` null \u21c4 absent wrapper, `3` \u21c4 `3`.\n- [ ] A `DeployArtifactsCommandDto` serialized WITHOUT field 9 (old central) maps to `Secrets == null`.\n- [ ] No `` item left active in the csproj.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter FullyQualifiedName~SiteCommandDtoMapperSecretsTests` \u2192 all pass.\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/SecretArtifact.cs\", \"src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/DeployArtifactsCommand.cs\", \"src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/ArtifactDeploymentResponse.cs\", \"src/ZB.MOM.WW.ScadaBridge.Communication/Protos/site_command.proto\", \"src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteCommandDtoMapper.cs\"], \"verifyCommand\": \"dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter FullyQualifiedName~SiteCommandDtoMapperSecretsTests\", \"acceptanceCriteria\": [\"SecretArtifact round-trips byte-for-byte\", \"null Secrets stays null / absent\", \"SecretsApplied null and 3 round-trip\", \"no active Protobuf item committed\"], \"modelTier\": \"standard\"}\n```"
},
{
"id": 3,
"subject": "Task 3: `SecretReferenceScanner`",
"status": "pending",
"blockedBy": [],
"description": "**Goal:** A pure function returning, per site, the distinct `secret:` references found anywhere in its data connections' primary/backup JSON, each with the referencing connection names.\n\n**Files:**\n- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Secrets/SecretReferenceScanner.cs`\n- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Secrets/SecretReferenceScannerTests.cs`\n\n**Acceptance Criteria:**\n- [ ] Finds `secret:` string values at any depth (objects and arrays), prefix case-insensitive, name trimmed.\n- [ ] Ignores non-string values, strings merely containing `secret:` mid-text, `${secret:X}` (node-local config form), empty names, and unparseable JSON (no throw).\n- [ ] Output ordinal-sorted by name; each entry lists referencing connections ordinal-sorted and de-duplicated.\n- [ ] Bootstrap names (`SB-GRPC-PSK-` prefix) are excluded.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter FullyQualifiedName~SecretReferenceScannerTests` \u2192 all pass.\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.ScadaBridge.Commons/Types/Secrets/SecretReferenceScanner.cs\", \"tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Secrets/SecretReferenceScannerTests.cs\"], \"verifyCommand\": \"dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter FullyQualifiedName~SecretReferenceScannerTests\", \"acceptanceCriteria\": [\"nested/case-insensitive refs found in primary+backup\", \"non-refs, ${secret:}, bad JSON, bootstrap names ignored\", \"sorted, de-duplicated output\"], \"modelTier\": \"mechanical\"}\n```"
},
{
"id": 4,
"subject": "Task 4: Secrets in the artifact revision hash",
"status": "pending",
"blockedBy": [
2
],
"description": "**Goal:** `ArtifactRevisionHashService` folds the command's secrets (and missing-secret markers) into the hash; sites with no secret refs keep their existing hash.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Artifacts/DeployArtifactsCommand.cs` (append `IReadOnlyList? MissingSecrets = null` \u2014 central-only, never mapped to proto; document that on the parameter)\n- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs`\n\n**Acceptance Criteria:**\n- [ ] A command with `Secrets == null` and `MissingSecrets == null` hashes identically to the pre-change golden value (add a golden-hash test computed on `main` BEFORE the change).\n- [ ] Changing a secret's revision, ciphertext, wrapped DEK or KekId changes the hash; reordering secrets does not.\n- [ ] A missing name hashes differently from the same name present.\n- [ ] `HashableRecords_PropertiesDeclaredAlphabetically` covers `HashableSecret` and passes.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter FullyQualifiedName~ArtifactRevisionHashServiceTests` \u2192 all pass.\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs\", \"tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs\"], \"verifyCommand\": \"dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter FullyQualifiedName~ArtifactRevisionHashServiceTests\", \"acceptanceCriteria\": [\"golden pre-change hash unchanged when no secrets\", \"revision/ciphertext/wrapped DEK/KekId changes move the hash; order does not\", \"missing marker differs from present\", \"alphabetical-properties test passes\"], \"modelTier\": \"standard\"}\n```"
},
{
"id": 5,
"subject": "Task 5: Central shared secret store \u2014 `Secrets:Store:Provide",
"status": "pending",
"blockedBy": [],
"description": "**Goal:** A Central node can use the shared SQL-Server secret store without the hub; a clustered Central on `Sqlite` warns.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json` (`\"Store\": { \"Provider\": \"Sqlite\" }` under `Secrets`, with an `_comment`)\n- Test: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs` (extend), `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs` (extend)\n\n**Acceptance Criteria:**\n- [ ] Central + `Provider=SqlServer` + connection string \u2192 `ISecretStore` resolves to the library's SQL-Server store; no hub endpoint mapped.\n- [ ] Central + `Provider=SqlServer` + blank connection string \u2192 startup failure naming `Secrets:SqlServer:ConnectionString`; a `${secret:` value is rejected (bootstrap rule, same message style as the existing Grpc-mode check).\n- [ ] Site role ignores `Secrets:Store:Provider` (always local SQLite).\n- [ ] Clustered Central (seed-nodes > 1) with `Provider=Sqlite` logs one Warning naming the per-node divergence risk; unknown provider value fails validation.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter \"FullyQualifiedName~SecretsReplicationWiringTests|FullyQualifiedName~StartupValidatorTests\"` \u2192 all pass.\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs\", \"src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs\", \"src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json\"], \"verifyCommand\": \"dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter \\\"FullyQualifiedName~SecretsReplicationWiringTests|FullyQualifiedName~StartupValidatorTests\\\"\", \"acceptanceCriteria\": [\"Central+SqlServer resolves shared SQL store, no hub\", \"blank or ${secret:} connection string fails naming the key\", \"Site ignores provider\", \"clustered Central on Sqlite warns; unknown provider fails\"], \"modelTier\": \"standard\"}\n```"
},
{
"id": 6,
"subject": "Task 6: Central \u2014 collect a site's secrets, fail closed, log",
"status": "pending",
"blockedBy": [
2,
3,
4,
5
],
"description": "**Goal:** `ArtifactDeploymentService` adds each site's referenced secrets (ciphertext rows from `ISecretStore`) to its command, fails that site closed on a missing secret, logs names/revisions, and `ComputeCurrentArtifactHashesAsync` includes missing markers.\n\n**Files:**\n- Create: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/SiteSecretsCollector.cs`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs` (`BuildDeployArtifactsCommandAsync`, `DeployCoreAsync`, `RetryForSiteAsync`, `ComputeCurrentArtifactHashesAsync`)\n- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ZB.MOM.WW.ScadaBridge.DeploymentManager.csproj` (``), DeploymentManager DI registration (`SiteSecretsCollector` singleton)\n- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/SiteSecretsCollectorTests.cs` (new), `ArtifactDeploymentServiceTests.cs` (extend)\n\n**Acceptance Criteria:**\n- [ ] A site whose data connections reference `A` and `B` gets a command whose `Secrets` are exactly A and B's `StoredSecret` rows mapped field-for-field; other central secrets are not included.\n- [ ] A referenced name that is absent or `IsDeleted` \u2192 that site's `SiteArtifactResult` fails with `Secret 'X' referenced by data connection(s) C1, C2 is not in the central secret store`, nothing is sent to that site, other sites in the batch still deploy.\n- [ ] `ComputeCurrentArtifactHashesAsync` puts missing names in `MissingSecrets` instead of failing, so such a site reads *Out of date*.\n- [ ] Central logs Information `Deploying {Count} secret(s) to site {SiteId}: {Secrets}` (formatted `name@rN`) and Error for missing; a test asserts no log/exception text contains the secret's plaintext or base64 ciphertext.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter \"FullyQualifiedName~SiteSecretsCollectorTests|FullyQualifiedName~ArtifactDeploymentServiceTests\"` \u2192 all pass.\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.ScadaBridge.DeploymentManager/SiteSecretsCollector.cs\", \"src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs\"], \"verifyCommand\": \"dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter \\\"FullyQualifiedName~SiteSecretsCollectorTests|FullyQualifiedName~ArtifactDeploymentServiceTests\\\"\", \"acceptanceCriteria\": [\"only referenced rows shipped, field-for-field\", \"missing/tombstoned ref fails only that site with the named message, nothing sent\", \"current-hash uses missing markers\", \"logs name@revision, never material\"], \"modelTier\": \"standard\"}\n```"
},
{
"id": 7,
"subject": "Task 7: Central \u2014 stamp gating for old sites + audit row",
"status": "pending",
"blockedBy": [
6
],
"description": "**Goal:** Central never stamps a site *Current* unless the site confirmed applying every shipped secret, and every secret deployment writes one audit row.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs`\n- Modify: `SiteArtifactResult` record (same file or its Commons location) \u2014 append `int? SecretsDeployed = null`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DeployCommands.cs` only if its table output enumerates columns (JSON output carries the new field automatically)\n- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs`\n\n**Acceptance Criteria:**\n- [ ] Command with \u2265 1 secret + response `Success=true, SecretsApplied=null` \u2192 site result is a failure `Site build predates secret deployment \u2014 upgrade the site before deploying secrets`; hash NOT stamped.\n- [ ] `SecretsApplied` \u2260 shipped count \u2192 failure `Site applied {n} of {m} secrets`; not stamped.\n- [ ] Command with no secrets \u2192 old-site response (`SecretsApplied=null`) still succeeds and stamps (unchanged behaviour).\n- [ ] One `IAuditService.LogAsync(user, \"DeploySiteSecrets\", \"SiteSecrets\", siteIdentifier, siteName, { deploymentId, secrets:[{name,revision}], missing:[name], success, error })` per site whose command carried or would have carried secrets (incl. the fail-closed case); none for sites with no refs. Audit has no material.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter FullyQualifiedName~ArtifactDeploymentServiceTests` \u2192 all pass.\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs\"], \"verifyCommand\": \"dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter FullyQualifiedName~ArtifactDeploymentServiceTests\", \"acceptanceCriteria\": [\"old site + secrets -> failure, not stamped\", \"partial apply -> failure, not stamped\", \"no secrets -> unchanged behaviour\", \"one DeploySiteSecrets audit row per secret-bearing site, no material\"], \"modelTier\": \"standard\"}\n```"
},
{
"id": 8,
"subject": "Task 8: Site storage \u2014 `deployed_secrets` (replicated) + `mi",
"status": "pending",
"blockedBy": [
2
],
"description": "**Goal:** The site database has a replicated ciphertext table for deployed secrets and a node-local ledger of mirrored names, with full-set accessors.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs`\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs` (`\"deployed_secrets\"` appended to `ReplicatedTables`; update the \"ten tables\" comments to eleven; `mirrored_secrets` deliberately NOT registered, with a comment why)\n- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/DeployedSecretsStorageTests.cs` (new); existing `SiteLocalDbReplicatedTableFkGuardTests` must still pass\n\n**Acceptance Criteria:**\n- [ ] `UpsertDeployedSecretsAsync(IReadOnlyList, deploymentId)` upserts rows (`is_removed = 0`) and marks every other row `is_removed = 1`; returns `(applied names, newly removed names)`.\n- [ ] `GetDeployedSecretsAsync()` returns all rows incl. removed with their `SecretArtifact` round-tripped from `row_json`.\n- [ ] `mirrored_secrets` get/add/remove work; it is not in `ReplicatedTables`.\n- [ ] A security test asserts `row_json` holds only base64 ciphertext fields (deserializes to `SecretArtifact`, no extra properties).\n- [ ] FK guard test green (no FKs on either table).\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeployedSecretsStorageTests` and `dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter FullyQualifiedName~SiteLocalDbReplicatedTableFkGuardTests` \u2192 all pass.\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageSchema.cs\", \"src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs\", \"src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs\"], \"verifyCommand\": \"dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeployedSecretsStorageTests\", \"acceptanceCriteria\": [\"full-set upsert marks absent rows removed\", \"rows round-trip SecretArtifact\", \"mirrored_secrets local only\", \"row_json ciphertext-only security test\", \"FK guard green\"], \"modelTier\": \"standard\"}\n```"
},
{
"id": 9,
"subject": "Task 9: `DeployedSecretsMirror` \u2014 per-node apply + verify",
"status": "pending",
"blockedBy": [
8
],
"description": "**Goal:** Every site node reconciles `deployed_secrets` into its own `secrets.db` (apply live rows, delete removed rows it previously mirrored), verifies each deployed secret decrypts, logs changes, and can be invoked synchronously.\n\n**Files:**\n- Create: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/DeployedSecretsMirror.cs` (service + `IHostedService` loop)\n- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs` (+ `DeployedSecretsMirrorInterval`, default 15 s) and `SiteRuntimeOptionsValidator.cs` (> 0)\n- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj` (`ZB.MOM.WW.Secrets.Abstractions`), SiteRuntime DI (`AddSingleton()` + `AddHostedService(sp => sp.GetRequiredService())`)\n- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Secrets/DeployedSecretsMirrorTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `ReconcileAsync()` applies each live row with `ISecretStore.ApplyReplicatedAsync` (mapped back to `StoredSecret`), records it in `mirrored_secrets`, and returns a `MirrorResult(Applied, Removed, Failed)`.\n- [ ] A removed row is deleted from the store only if its name is in `mirrored_secrets`; a same-named out-of-band secret never mirrored is left alone.\n- [ ] Each live secret is trial-resolved via `ISecretResolver`; a failure (null or exception) is a `Failed` entry `Secret '{Name}' (kek {KekId}) did not decrypt on this node \u2014 the node's master key differs from central's` and is logged at Error.\n- [ ] Logging: Information per applied/removed secret and one summary when anything changed; Debug when in sync; never values.\n- [ ] The hosted loop runs once at start and every `DeployedSecretsMirrorInterval`; a reconcile exception is logged and the loop continues.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeployedSecretsMirrorTests` \u2192 all pass.\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Secrets/DeployedSecretsMirror.cs\", \"src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs\", \"src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptionsValidator.cs\"], \"verifyCommand\": \"dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeployedSecretsMirrorTests\", \"acceptanceCriteria\": [\"live rows applied via ApplyReplicatedAsync and ledgered\", \"removed rows deleted only if previously mirrored\", \"decrypt failure reported with KekId at Error\", \"change/in-sync logging without values\", \"hosted loop at start + interval, survives exceptions\"], \"modelTier\": \"standard\"}\n```"
},
{
"id": 10,
"subject": "Task 10: Site apply \u2014 secrets in `HandleDeployArtifacts`",
"status": "pending",
"blockedBy": [
9
],
"description": "**Goal:** The active site node writes shipped secrets to `deployed_secrets`, runs the mirror synchronously, and answers with `SecretsApplied` or a failure.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs` (`HandleDeployArtifacts`, constructor gets `DeployedSecretsMirror?`)\n- Modify: the actor's Props/DI construction site to pass the mirror\n- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorArtifactSecretsTests.cs` (new; follow the existing artifact-apply actor tests)\n\n**Acceptance Criteria:**\n- [ ] `Secrets == null` \u2192 `deployed_secrets` untouched, response `SecretsApplied = null` (old-central compatible).\n- [ ] `Secrets` present (incl. empty) \u2192 full-set write, synchronous `ReconcileAsync`, response `Success=true, SecretsApplied = shipped count` when the mirror reports no failures.\n- [ ] Any mirror failure \u2192 `Success=false`, `ErrorMessage` = the failure lines joined with `; `, `SecretsApplied` = count applied without failure.\n- [ ] Site Information log `Applying {Count} deployed secret(s), deploymentId={DeploymentId}` + the mirror's per-secret lines.\n\n**Verify:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeploymentManagerActorArtifactSecretsTests` \u2192 all pass.\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs\"], \"verifyCommand\": \"dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter FullyQualifiedName~DeploymentManagerActorArtifactSecretsTests\", \"acceptanceCriteria\": [\"null Secrets leaves table and SecretsApplied null\", \"secrets -> full-set write + sync mirror + SecretsApplied=count\", \"mirror failure -> Success=false with KekId message\", \"apply logging\"], \"modelTier\": \"standard\"}\n```"
},
{
"id": 11,
"subject": "Task 11: Retire the gRPC hub for ScadaBridge; switch the rig",
"status": "pending",
"blockedBy": [
7,
10,
1
],
"description": "**Goal:** ScadaBridge no longer composes the secrets hub (client or server); config asking for it fails fast; the docker rig's central pair uses `Secrets:Store:Provider=SqlServer` and site-a gets secrets only via artifact deploy.\n\n**Files:**\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs` (delete the Grpc hub/hub-client and SqlServer-replication branches; keep local SQLite + Task 5's shared store), `Program.cs` (remove `MapScadaBridgeSecretsHub`), `StartupValidator.cs` (reject `Secrets:Replication:Enabled=true`: `Secrets replication (the gRPC hub) is retired in ScadaBridge \u2014 sites receive secrets with Deploy Artifacts; central uses Secrets:Store:Provider=SqlServer`)\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json` (remove `Replication`/`GrpcHub` blocks; keep `SqlServer.ConnectionString`/`SchemaName`; update comments)\n- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj` (drop `ZB.MOM.WW.Secrets.Replicator.Grpc` reference) and `Directory.Packages.props` if nothing else uses it\n- Modify: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs`, `MappedGrpcAuthenticationCoverageTests.cs` (hub route gone)\n- Modify: `docker/docker-compose.yml` (delete `x-secrets-hub-env` + `x-secrets-hub-site-env` anchors and their merges; central-a/b get `Secrets__Store__Provider: \"SqlServer\"` + the existing `x-secrets-hub-central-env` connection string renamed `x-secrets-central-store-env`; the rig KEK `ZB_SECRETS_MASTER_KEY` goes on central-a/b AND site-a-a/b \u2014 every node that handles deployed secrets must share it; site-b/site-c keep a DIFFERENT dev KEK so the Task 13 mismatch negative is real)\n\n**Acceptance Criteria:**\n- [ ] No ScadaBridge code references `AddZbSecretsGrpcHub`, `AddZbSecretsGrpcHubClient` or `MapScadaBridgeSecretsHub`.\n- [ ] `Secrets:Replication:Enabled=true` on either role \u2192 startup failure with the message above.\n- [ ] Wiring tests cover: Central Sqlite, Central SqlServer store, Site local \u2014 all green; hub-route auth coverage test updated.\n- [ ] `docker compose config` renders; central-a/b carry `Secrets__Store__Provider=SqlServer`; no node carries `Secrets__Replication__*` or `Secrets__GrpcHub__*`.\n\n**Verify:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` \u2192 0 errors; `dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests` \u2192 all pass; `docker compose -f docker/docker-compose.yml config | grep -c Secrets__Replication` \u2192 `0`.\n\n```json:metadata\n{\"files\": [\"src/ZB.MOM.WW.ScadaBridge.Host/SecretsRegistration.cs\", \"src/ZB.MOM.WW.ScadaBridge.Host/Program.cs\", \"src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs\", \"docker/docker-compose.yml\"], \"verifyCommand\": \"dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests\", \"acceptanceCriteria\": [\"no hub wiring left in ScadaBridge\", \"Replication:Enabled=true fails startup with the retirement message\", \"wiring tests green\", \"compose renders; centrals SqlServer store; no hub env anywhere\"], \"modelTier\": \"standard\"}\n```"
},
{
"id": 12,
"subject": "Task 12: Documentation sync",
"status": "pending",
"blockedBy": [
11
],
"description": "**Goal:** The spec docs, CLAUDE.md, the deferred-work register and the umbrella index describe secrets-via-artifacts and the hub retirement.\n\n**Files:**\n- Modify: `docs/requirements/Component-DeploymentManager.md` (artifact set + secrets, fail-closed, hash, audit), `Component-SiteRuntime.md` (deployed_secrets, mirror, options), `Component-Host.md` (Secrets section: providers, hub retired, bootstrap secrets out-of-band), `Component-CentralUI.md` (Sites page: secrets make a site out of date; failure messages)\n- Modify: `docs/deployment/topology-guide.md` (KEK must match on every central + site node; standby convergence interval)\n- Modify: `CLAUDE.md` (replace the secrets/hub facts with one bullet under *Templates & Deployment*; update the LocalDb bullet \"ten tables\" \u2192 eleven incl. `deployed_secrets`)\n- Modify: `docs/plans/2026-07-08-deferred-work-register.md` (new row: vd03 + QA rollout \u2014 zbsecrets store, `Secrets:Store:Provider=SqlServer` on both centrals, KEK alignment check, recreate hand-seeded site secrets centrally, Deploy Artifacts)\n- Modify: `../scadaproj/CLAUDE.md` (ScadaBridge row: short line; Secrets 0.6.3 note in the libraries table) and `../scadaproj/ZB.MOM.WW.Secrets/CLAUDE.md` (ScadaBridge no longer a hub consumer)\n\n**Acceptance Criteria:**\n- [ ] `grep -rn \"GrpcHub\\|secrets hub\" docs/requirements CLAUDE.md` returns only historical/retired mentions.\n- [ ] Register row lists the four rollout prerequisites and the trigger (\"user asks for the vd03/QA secrets window\").\n- [ ] Umbrella index row stays a few lines.\n\n**Verify:** `grep -rn \"deployed_secrets\" docs/requirements/Component-SiteRuntime.md CLAUDE.md` \u2192 matches in both; `git diff --stat` shows only doc files.\n\n```json:metadata\n{\"files\": [\"docs/requirements/Component-DeploymentManager.md\", \"docs/requirements/Component-SiteRuntime.md\", \"docs/requirements/Component-Host.md\", \"CLAUDE.md\", \"docs/plans/2026-07-08-deferred-work-register.md\"], \"verifyCommand\": \"grep -rn deployed_secrets docs/requirements/Component-SiteRuntime.md CLAUDE.md\", \"acceptanceCriteria\": [\"component docs + topology guide updated\", \"CLAUDE.md bullet + LocalDb eleven tables\", \"register rollout row with 4 prerequisites\", \"umbrella + Secrets CLAUDE.md updated\"], \"modelTier\": \"mechanical\"}\n```"
},
{
"id": 13,
"subject": "Task 13: Full test run, rig rebuild, live gate",
"status": "pending",
"blockedBy": [
12
],
"description": "**Goal:** The whole solution is green and the rebuilt docker rig demonstrates secrets deploying with artifacts end to end, including standby convergence, staleness, removal, fail-closed and KEK mismatch.\n\n**Files:**\n- Create: `docker/verify-site-secrets.sh` (idempotent gate script, prints PASS/FAIL per check, exits non-zero on any FAIL)\n- Create: `docs/plans/2026-09-28-site-secrets-artifact-deploy-live-gate.md` (results record)\n\n**Acceptance Criteria:**\n- [ ] `dotnet test ZB.MOM.WW.ScadaBridge.slnx` green except the known env-only Playwright residue (list any failure explicitly).\n- [ ] `bash docker/deploy.sh` succeeds; all 8 nodes `/health/ready` 200.\n- [ ] Gate checks (each PASS):\n 1. Create secret `sb-gate-mxgw` at central (Secrets CLI or `/admin/secrets`) and an unbound MxGateway data connection on site-a with `\"ApiKey\":\"secret:sb-gate-mxgw\"` \u2192 Sites page/`site list` shows site-a **Out of date**, site-b/site-c unchanged.\n 2. `deploy artifacts --site-id ` \u2192 success, `secretsDeployed: 1`; site-a **Current**.\n 3. Both site-a nodes' `secrets.db` contain `sb-gate-mxgw` at the central revision (`docker exec` + `sqlite3` on the library table, ciphertext compare by SHA-256) \u2014 the standby within 30 s.\n 4. Rotate the secret at central \u2192 site-a **Out of date** \u2192 deploy \u2192 both nodes at the new revision.\n 5. Remove the `secret:` reference \u2192 deploy \u2192 both nodes no longer hold the secret; an out-of-band secret seeded on site-a-a beforehand is still present.\n 6. Reference a non-existent `sb-gate-missing` \u2192 deploy site-a fails with the named message; site-a stays **Out of date**; nothing changed on the site.\n 7. Same connection on site-b (different dev KEK) \u2192 deploy fails with the KEK-mismatch message naming the KekId.\n 8. Audit log has one `DeploySiteSecrets` row per secret-bearing deploy; `grep` of all 8 nodes' logs for the secret's plaintext and base64 ciphertext \u2192 0 hits.\n- [ ] Gate script and results record committed; gate objects cleaned up by the script's teardown.\n\n**Verify:** `bash docker/verify-site-secrets.sh` \u2192 `PASS 8/8`.\n\n```json:metadata\n{\"files\": [\"docker/verify-site-secrets.sh\", \"docs/plans/2026-09-28-site-secrets-artifact-deploy-live-gate.md\"], \"verifyCommand\": \"bash docker/verify-site-secrets.sh\", \"acceptanceCriteria\": [\"solution tests green (env-only residue listed)\", \"rig rebuilt, 8 nodes ready\", \"gate checks 1-8 PASS incl. standby convergence, removal, fail-closed, KEK mismatch, audit + no-material grep\", \"gate script + results record committed\"], \"modelTier\": \"standard\"}\n```"
}
],
"lastUpdated": "2026-09-28T20:58:11.605203Z"
}