25 KiB
Medium-Findings Remediation Plan — 2026-08-19 Arch Review
Scope: all Medium and Medium-High findings from the 2026-08-19 updated architectural
review (report artifact 43c7fa20-9626-4d59-a9c9-061524794660). The four Highs (P2-1, P2-2,
P6-1, P6-2) were fixed in the preceding wave (c4d3c8b8..d18f344e, deployed to the rig).
Baseline: main @ d18f344e.
Two borderline findings are rounded in because they are cheap and adjacent to in-scope work: P3-3 (Low-Med, Debug View dual-address requirement) and P4-4 (Med-Low, standby event-log retention). All plain-Low findings stay out of scope (P1-4, P2-5, P2-6, P3-4, P3-5, P4-5..P4-7, P5-3..P5-6, P6-10..P6-12, P7-5, P7-6, P8-7..P8-10).
Register consequences (handled at integration, NOT by task agents, to avoid file contention): row 99 reopened+closed by T8 (P6-3 shows the closure was defective); row 95 reopened+closed by T17 (P8-6 shows its premise correction was wrong).
Execution model (same as the High wave): file-disjoint tasks, one dedicated manual git
worktree + model-matched subagent each, pathspec commits, cherry-pick onto main,
combined build + affected suites, git diff review, single merge session. Two waves.
Decision made in this plan (owner may override later): P7-2 takes the conservative
option — bulk DeploySiteAsync skips Disabled instances and surfaces them in the
result as skipped, matching the reconcile path's deliberate preservation of Disabled.
Rationale: the alternative (silently force-enabling deliberately-stopped equipment on a
bulk push) is the hazard the finding names.
Wave 1
T1 (Opus) — P1-1 + P1-2: DeploymentManager races (heal resurrection; redeploy override reset)
Files: src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs,
InstanceActor.cs, the EnsureInstanceStartedResult message, InstanceActorTests.cs.
- P1-1 (Med):
HandleEnsureInstanceStarted(:1571) re-reads the store off-thread but captures no_storeWriteGeneration; the result handler never callsIsStoreSnapshotStale(unlike boot :521 and sweep :1726). A Disable/Delete processed between read and result resurrects the instance permanently (sweep has no runtime→store stop leg; disabled rowscontinue, deleted rows are absent). Verifier note: trigger is the ONE-SHOT startup reconcile (SiteReconciliationActor.cs:267), so the window is boot-adjacent; the stale comment at :1567-1570 must go. Fix: thread the generation captured at read throughEnsureInstanceStartedResult; discard on mismatch (boot/sweep pattern). Test: heal result arriving after a Disable/Delete is dropped. - P1-2 (Med, arguably Med-High): ApplyDeployment creates the new InstanceActor (:1202)
BEFORE the Task.Run that ends in
ClearStaticOverridesAsync(:1222-1234); the new actor's PreStart override load typically beats the clear (verifier: likely interleaving) → pre-redeploy overrides applied for the actor's lifetime, violating "reset on redeployment". Leg 2: old actor's PostStop fire-and-forgetFlushDirtyStaticOverrides(InstanceActor.cs:363-364) can durably commit AFTER the clear. Fix: on redeploy, clear overrides in the persistence task BEFORECreateInstanceActor(or gate the override load behind the persistence result), and order the old actor's final flush before the clear (e.g. await/flush before actor creation — Terminated is already awaited :1004-1066, so flushing synchronously in PostStop's stop path or draining before clear are both acceptable; pick the minimal ordering that closes both legs).InstanceActorTests.cs:355-356pins the WRONG contract — fix the test to pin the right one. Tests: redeploy never applies pre-redeploy overrides; late flush cannot resurrect.
T2 (Sonnet) — P1-3: Alarm on-trigger runs bypass the stuck-script watchdog
File: src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRunLauncher.cs
(RunAlarmScriptAsync :454-537 / LaunchAlarmScript :149-186).
Script path arms watchdog + slot capture (:244-253); alarm path does none on the same
bounded pool — a wedged alarm body silently costs a pool thread forever (bounded 4/alarm
by the in-flight cap), with no site event, no detach, no healthCollector error increment,
no ScriptRunSummaryRecorder. Fix: mirror the script path exactly — slot/stamp/
watchdog + health/summary threading (timeout catch today is LogWarning-only :518-524).
Tests: first tests to drive LaunchAlarmScript — wedged alarm body triggers watchdog
detach + site event + DetachedScriptThreads; timeout increments health error.
T3 (Opus) — P2-3 + P2-4: DCL ConnectResult generation; OPC UA batch rollback
Files: src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs,
Adapters/RealOpcUaClient.cs.
- P2-3 (Med):
ConnectResultpiped untagged (:777-787). Reconfigure mid-connect: old adapter'sConnectResult(true)drives ReSubscribeAll+BecomeConnected against the NEW never-connected adapter; new adapter's result dropped in Connected; idempotent- reconfigure guard (:644-652) then blocks recovery until restart/redeploy. Fix: tagConnectResultwith the adapter generation (stamped at connect launch, exactly the P2-1 pattern now onSubscribeCompleted— reuse it); drop mismatches. Tests mirrorDataConnectionStaleCompletionTestsfor the connect leg. - P2-4 (Med):
CreateSubscriptionsCoreAsync(RealOpcUaClient.cs:663): mid-batchApplyChangesAsyncthrow leaves allcreateditems registered (_callbacks/_itemShard/ ItemCount/SDK items :628-661) with no rollback, andIsConnectionLevelFailure(DataConnectionActor.cs:1464-1471) missesServiceResultException→ chunk treated per-tag unresolved, each backoff probe RE-ADDS items → unbounded shard growth. Fix: unwindcreatedviaReleaseFailedItemon throw; classify SDK bad-session codes connection-level. Tests: fake-SDK mid-batch throw leaves no residue; bad-session code drives the connection-level path.
T4 (Opus) — P3-1 + P3-2 + P3-3: gRPC client lifecycle/disposal races
Files: src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs,
Grpc/SitePairChannelProvider.cs, DebugStreamService.cs.
- P3-1 (Med):
RegisterSubscription(:147-153) disposes the prior handler's CTS while itsConsumeStreamAsyncmay still read the token (ODE at :219/:294/:359/:364);Unsubscribe(:455-462) unguarded Cancel;ReleaseResources(:609-619) unguarded iteration whose ODE aborts the factory's DisposeAsync loop leaving later clients undisposed; an ODE fromprior.Cancel()escapes the NEW SubscribeAsync with the CURRENT generation → phantom fault. Fix: cancel-only on replacement (dispose only from the owning path), ODE-guard every Cancel, keep factory disposal loop fault-isolated.SiteStreamGrpcClientTests.cs:680pins current behavior — re-pin. - P3-2 (Med):
UpdateSite/RemoveSite(:123-131/:155-159) Dispose channels underpair.GatewhileExecuteAsyncinvokes outside the lock (:203-215) → 60s DB refresh with an edited address kills in-flight RPCs (multi-minute DeployArtifactsCommand) with ODE/Cancelled (not Unavailable → no cross-node retry). Fix: deferred disposal — park replaced channels until quiescent or time-delayed; mirror the stream factory's redesign rationale (SiteStreamGrpcClientFactory.cs:12-22). - P3-3 (Low-Med, rounded in):
DebugStreamService(:111-114) throws unless BOTH gRPC node addresses are set, though single-endpoint sites are supported elsewhere. Fix: apply the N9 degeneration (SiteAlarmLiveCacheService.cs:324-330): reuse the sole endpoint.
T5 (Opus) — P4-1 + P4-3: LocalDb sync divergence + liveness (cross-repo)
Files (scadaproj): ~/Desktop/scadaproj/ZB.MOM.WW.LocalDb/src/.../Internal/SyncSession.cs,
OplogStore.cs, SyncBackgroundService.cs, tests. Files (here):
src/ZB.MOM.WW.ScadaBridge.Host/Program.cs (site Kestrel h2 keepalive),
Directory.Packages.props (0.3.1 → 0.3.2 when published).
- P4-1 (Med):
PumpLoopAsyncnever checksneeds_snapshotafter a cap-prune thoughOplogStore.ReadBatchAboveAsyncdocuments the obligation (OplogStore.cs:27-29) — maintenance prunes above_sentThruSeqon a slow-but-alive peer → pump silently skips the gap, pair diverges for the session's lifetime (heals only on reconnect). Fix: fault the live session whenEnforceCapsAsyncprunes into the in-flight range (the 0.3.1 fault-teardown control-lane flush already exists — reuse it). Test: prune during an active session faults it; reconnect serves the snapshot. - P4-3 (Med): no keepalive anywhere on the sync stream: bare
GrpcChannel.ForAddressclient-side, no KestrelHttp2.KeepAlivePingDelayon site 8083 → hard crash/partition of the dialing node leaves the passive node's handler hung on MoveNext holding_active=1, rejecting the rebooted initiator's reconnectsAlreadyExists, possibly forever; zombie side reportsLocalDbReplicationConnected=true. Fix: clientSocketsHttpHandlerKeepAlivePing (delay/timeout, always-with-active-requests) inSyncBackgroundService; Kestrel h2 keepalive on the site gRPC listener in HostProgram.cs; AND newest-wins eviction (or a bounded read deadline) on the server's single-stream guard so a fresh authenticated Sync can displace a dead one. - Version/publish: bump LocalDb to 0.3.2, commit in scadaproj (do NOT push);
build/push.shneeds the user-heldGITEA_NUGET_KEY— if absent, leave the ScadaBridgeDirectory.Packages.propsbump as a flagged follow-up and land only the Host keepalive here. The ScadaBridge-side Kestrel change is independent and lands regardless.
T6 (Sonnet) — P4-2 + P4-4: audit-writer durability; standby event-log retention
- P4-2 (Med, arguably Med-High):
AuditLog/Site/SqliteAuditWriter.cs— per-rowTrySetResult(:487/:499) BEFOREtransaction.Commit()(:503); commit failure (SQLITE_FULL/IOERR) no-opsTrySetException→FallbackAuditWritersees success, no ring fallback, whole batch silently lost in exactly the outage the ring exists for;SiteAuditWriteFailuresstays 0. Bonus (verifier): a mid-batch insert failure on row k loses rows 0..k-1 the same way. Fix: complete TCSes only after commit (the patternSiteEventLogger.FlushBatchalready uses :319/:357-361); on commit/insert failure fail EVERY row's TCS so the ring fallback engages. Tests: failed commit → all TCSes faulted + ring receives batch + failure counter increments. - P4-4 (Med-Low, rounded in):
SiteEventLogging/EventLogPurgeService.cs:215-219early-exits on the standby on the stale premise "standby receives no writes" — DCL actors run on both nodes since rows 89/90/97 and write site events. On an unreplicated site (site-b/c posture) the standby'ssite_eventsgrows forever. Fix: let the standby run retention/cap against its own file (no-op-safe on a replicated pair — deletes are CDC-captured and LWW-converge; say so in a comment). Test: purge runs when not primary.
T7 (Sonnet) — P5-1 + P5-2: central ingest fallback budget; SiteCalls query shape
- P5-1 (Med):
AuditLog/Central/AuditLogIngestActor.cs:372-405— per-entry fallback loop passes NO token/timeout (contrastOnIngestAsync's 5sIngestFallbackBudget:268); each entry sits insideSqlServerRetryingExecutionStrategy5×30s → minutes insideReceiveAsync, singleton mailbox frozen, site re-sends amplify. Fix: mirrorOnIngestAsync— short linked CTS +IngestSqlCommandTimeoutper entry, pass the token toInsertIfNotExistsAsync/UpsertAsync. Test: fallback loop honors budget. - P5-2 (Med):
ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs:301-331orders(CreatedAtUtc DESC, TrackedOperationId DESC)with no index leading onCreatedAtUtc(full scan+sort per unfiltered page, row-35 class) and binds nvarchar(4000) params againstIsUnicode(false)columns (may block seeks, incl. the keyset predicate :316; write path types VarChar deliberately :230). Fix: add the(CreatedAtUtc DESC, TrackedOperationId DESC)index via EF migration (BUILD FIRST —--no-buildscaffolds empty) + typed varchar params on the read path. Update the handwritten production SQL script if one exists for this table (migration-parity rule).
T8 (Sonnet) — P6-3: ParseEnumListStrict comma smuggling (register row 99 defective)
File: src/ZB.MOM.WW.ScadaBridge.Commons/Types/Audit/AuditQueryParamParsers.cs:117-119.
Enum.TryParse ORs comma-joined names for any enum; LooksNumeric checks first char
only; dense 0-based AuditStatus makes composites land on defined members —
"Forwarded,Attempted" = 1|2 = 3 = Delivered (silent wrong data); comma also smuggles
ordinals past the row-88/99 guard ("Failed,7" → Skipped). Server path confirmed:
AuditEndpoints.cs:656-658 passes the raw query value whole. Fix: reject any value
containing ',' before TryParse (per-token error, same UX as existing invalid-name error).
Tests: comma-joined names rejected; "Failed,7" rejected; single names/known-good paths
unchanged. Do NOT edit the register (integration does).
Wave 2
T9 (Opus) — P6-4 + P6-5: ManagementActor scoping gaps (single-writer lane)
File: src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs (sole owner
this wave).
- P6-4 (Med):
HandleListDataConnections(:1875-1881) takes no user, null role gate → lists every site's connections (scrubbed but topology-revealing existence oracle) whileHandleGetDataConnectionfail-closes (:1888-1900) — Get denies what List hands over. Fix: filter byPermittedSiteIdsexactly asHandleListSites(:1719-1729) /HandleListSecuredWrites(:1665-1695) do. Check the Design UI page's separateSiteRepository.GetAllDataConnectionsAsyncbypass (DataConnections.razor:309) — if it serves scoped users unfiltered, route it through scope too (same one-line filter). - P6-5 (Med): resolve-before-scope oracles outside row 98's five shapes: approve
(:1465-1474) and reject (:1627-1635) check not-found + status (leaking "is Executed,
not Pending") BEFORE
EnforceSiteScopeForIdentifier; same shapeHandleDebugSnapshot(:3604-3608) +HandleSubmitSecuredWrite(:1417-1423). Fix: hoist scope checks above resolution/status checks at all four sites (row-98DenyUnresolvedTargetIfScopedsemantics). Tests: out-of-scope caller gets the scope denial, not the status oracle, at each site.
T10 (Opus) — P6-6: Debug-stream session abandoned mid-start leaks permanently
Files: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/DebugView.razor(.cs)
(:493), DebugStreamHub.cs (:195).
Connect() awaits StartStreamAsync (≤30s) then assigns _session with no _disposed
re-check; if Dispose already ran, StopStream never fires and the singleton
DebugStreamService keepalive renews the registered session forever (:157/:213-219; the
5-min orphan net only reaps UNregistered sessions) — holds the site gRPC debug sub +
bridge actor + dead circuit graph until restart. Identical shape in DebugStreamHub.cs:195.
Fix: post-await _disposed re-check that stops the just-created session (both sites);
make Dispose vs late-assign race-safe (interlocked/lock — Blazor Sync context helps but
Dispose can run on a different flow). Tests: dispose-during-start stops the session and
leaves nothing registered. The lesser TTL-bounded instances (TransportImport slot,
Topology timer) are OPTIONAL — take them only if trivial with the same pattern.
T11 (Sonnet) — P6-7 + P6-8: audit export injection + CLI deadline
- P6-7 (Med):
ManagementService/AuditEndpoints.cs:810-840CSV quotes only on, " \r \n; leading= + - @ \tpass through → formula/DDE execution on operator open (CWE-1236); exported Target/ErrorMessage/Request/ResponseSummary/Extra carry inbound- API-caller text verbatim. Fix: prefix-escape cells starting with those chars ('prefix, the standard neutralization). Secondary: CSV omits ExecutionId/ ParentExecutionId/SourceNode vs JSONL and theAuditExportRow.cs:11-13"24-field" claim — add the columns and align the doc comment. - P6-8 (Med):
CLI/Commands/AuditExportHelpers.cs:129—SendGetStreamAsync(..., CancellationToken.None)afterHttpClient.Timeout = Infinite(residue of thee0e4b246timeout-honesty fix): a hung server hangs the CLI forever, and body reads (:141/:152)/CopyToAsync(:171) are tokenless (mid-stream fault → raw stack- truncated file). Fix: headers-received deadline + overall/inactivity deadline + Ctrl-C token wiring through all reads; friendly error on mid-stream fault. Tests: deadline fires; cancellation propagates.
T12 (Sonnet) — P6-9: LoginThrottle never throttles single-IP password spray
File: src/ZB.MOM.WW.ScadaBridge.CentralUI/Security/LoginThrottle.cs:189-190.
Key is {username}|{ip} → independent 5-failure budget per username per IP; a spray
(many usernames, one IP) is never throttled though the class doc claims spray protection.
Fix: add a secondary per-IP counter with a higher ceiling (e.g. 20-30 failures/window
→ per-IP lockout), layered on the existing per-pair key; keep the doc honest. Tests:
spray locks the IP; normal per-user behavior unchanged; distinct IPs unaffected.
T13 (Sonnet) — P7-1: ExternalSystem TimeoutSeconds silently dropped by bundles
Files: src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs:248,
EntitySerializer.cs:127-141, BundleImporter.cs:3087-3128, ArtifactDiff.cs:166-179,
RoundTripEquivalenceTests.cs:445-452.
Row-27 SHAPE recurrence: DTO lacks TimeoutSeconds (operative at runtime and deployed as
proto field 6); export never writes it, Add resets to 0, Overwrite keeps old, diff never
compares → a tuned-timeout-only bundle previews Identical and the target env silently runs
the gateway default. The round-trip guard is vacuous because the fixture never sets the
field. Fix: add the DTO field + both serializer directions + both import branches +
AddIfDifferent in the diff + a NON-DEFAULT fixture value so the reflection round-trip
guard actually covers it. Schema stays additive (schemaVersion unchanged — additive field).
T14 (Opus) — P7-2 + P7-3 + P7-4: bulk-deploy semantics, reconcile deadline, probe TOCTOU
Files: src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs,
StaleInstanceProbe.cs, FlattenSession.cs.
- P7-2 (Med, decided above):
DeploySiteAsyncdeploys ALL instances (:804) and every success runsforceEnabledState:true(:610-612) — bulk push re-enables deliberately- Disabled equipment while the reconcile path deliberately preserves Disabled (:1638-1649, pinned). Fix: skip Disabled instances in bulk deploy; surface them in the result as a skipped list with reason; pin with tests (zero Disabled cases exist in DeploySiteAsyncTests today). UpdateComponent-DeploymentManager.md(one sentence). - P7-3 (Med): phase-1
TryReconcileWithSiteAsyncper instance is bounded only by the 30s QueryTimeout → unreachable site × N instances ≈ hours serial before a single send; CLI's 5-min BulkDeployTimeout cancels and re-arms the identical cost. Fix: in bulk mode, short linked per-reconcile deadline AND a once-per-batch site reachability probe — first reconcile timeout marks the site unreachable for the rest of Prepare (instances proceed on DB state, which is what happens on timeout today anyway). - P7-4 (Med):
StaleInstanceProbe.cs:106-109re-reads per-template versions AFTER flatten (comment claims before) → a non-structural template edit during flatten pairs pre-edit hash with post-edit version; memo permanently serves not-stale. FlattenSession captures correctly (FlattenSession.cs:114) but doesn't expose it. Fix: expose the session's capturedMemberVersions; probe stores those instead of re-reading. Test: template edit mid-flatten still reports stale.
T15 (Sonnet) — P8-1: three SiteHealthReport fields never cross the wire
Files: src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlDtoMapper.cs:452,
the vendored centralcontrol proto (regen via bash docker/regen-proto.sh centralcontrol),
SiteRuntime SiteHealthCollector.cs:243/:317-327, HealthReportSender.cs:180.
DetachedScriptThreads, ScriptRunShedCount, SiteStreamAlarmDropCount are populated
site-side but ToDto omits all three (proto ends at field 29) — a watchdog-detaching/
shedding/alarm-dropping site looks healthy centrally. Aggravator: AddIntervalCounters
takes only the 5 original counters and HealthReportSender never passes the shed count →
a failed send permanently loses the interval's shed count (drained by
Interlocked.Exchange). Fix: proto fields 30-32 (additive, never reuse numbers),
mapper both directions, extend AddIntervalCounters so failed sends restore all drained
counters. Follow the vendored-proto workflow exactly (regen script restores the csproj; an
active <Protobuf> item must never be committed). Tests: mapper round-trips the three
fields; failed send restores shed count.
T16 (Sonnet) — P8-2 + P8-3 + P8-4: env2 topology drift + regen-proto trap
- P8-2 (Med):
docker-env2/docker-compose.yml— addinit: trueto all 4 services (docker/ has it ×8 with the issue-#34 rationale; env2's PID-1 boot failure wedges at 100% CPU instead of restart-looping). - P8-3 (Med): env2 centrals still carry the SEC-36-rotated LDAP bind cred in plaintext
(
central-node-a/appsettings.Central.json:28,34+ node-b), plus inline SQL passwords + JWT key. Fix: port the docker/ posture — compose env-var overrides for secrets, and the same LDAP fallback/DisableLogin arrangement docker/ uses. Do NOT invent new secret values; reference the same env vars docker/ consumes. - P8-4 (Med):
docker/regen-proto.sh:58—trap ... ERRdoesn't fire on Ctrl-C during the long build → active<Protobuf>left in the csproj (breaks Docker build). Fix: idempotent restore trap onEXIT(covers ERR/INT/TERM/success; guard so a successful run doesn't double-restore).
T17 (Sonnet) — P8-5 + P8-6: test-health (register rows 95/96/40/102 classes)
- P8-5 (Med): Communication.Tests: (a) add
xunit.runner.jsonmaxParallelThreads: 4(row-96 policy; ~14 TestKit classes + real Kestrel, last uncapped resource-heavy suite); (b)SiteAlarmLiveCacheTelemetryTests.cs:55-93asserts fixed baselines on the process-globalaggregators.activegauge with no[Collection]while two sibling suites move the instruments (row-102 shape) — apply the row-102 treatment (serialize via collection or delta-based assertions); (c) rework the 8 sleep-then-assert-absence sites inSiteAlarmAggregatorActorTests(:385-386, :624-625 et al) per row-40 (await the observable effect / use TestKit expectations, never wall-clock sleeps), and fix the discarded 100ms-tick actor inPendingDeploymentPurgeActorTests.cs:90(row-95 class: Watch + Stop + ExpectTerminated). - P8-6 (Med): SiteCallAudit.Tests DOES leak 100ms-tick actors (row 95's premise
correction was wrong):
SiteCallAuditPurgeTestsactors at :127/:152/:174 + discardedSys.ActorOf:209-212,SiteCallAuditReconciliationTests:58/:248/:372/:592 — zero Watch/Stop/ExpectTerminated in either file. Fix: row-82 treatment on every leaked actor. Do NOT edit the register (integration reopens/closes row 95 with a citation). - Test-only task: verify by running both suites (Communication.Tests 3× for flake confidence).
Shared constraints (all tasks)
- Fix + tests only; no drive-by refactors. Match surrounding style/comment density.
- Targeted verification per task: build touched project(s) + test project(s); run affected suites filtered. Full solution sweep once, at integration.
- Each agent works ONLY inside its own worktree (absolute paths,
git -C), commits with a pathspec limited to its own files, reports commit SHA + test results. Agents do NOT touchdocs/plans/2026-07-08-deferred-work-register.mdorCLAUDE.md(integration does). - Component-doc updates only where the doc states the now-contradicted behavior; one sentence each.
- Wire/proto changes additive only; never reuse field numbers.
Integration (per wave, then final)
- Cherry-pick wave commits onto
main(verify file-disjointness first; T4/T15/T17 share the Communication project — files must stay disjoint). dotnet build ZB.MOM.WW.ScadaBridge.slnx; run the touched suites; fulldotnet testsweep after wave 2.- Register edits: reopen+close rows 95 and 99 citing P8-6/P6-3; add rows for anything an agent flags as deliberately deferred.
git diffreview; commit plan doc + register/docs touch-ups; remove worktrees.- No push, no rig redeploy without the user.