Skip to content

Recs rebuild D2: persist the built RemediationAction (P2 reorder)#1059

Merged
erikdarlingdata merged 1 commit into
devfrom
feature/recs-d2-persist-action
Jun 5, 2026
Merged

Recs rebuild D2: persist the built RemediationAction (P2 reorder)#1059
erikdarlingdata merged 1 commit into
devfrom
feature/recs-d2-persist-action

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Scope

Implements D2 (persist the built RemediationAction) + P2 (pipeline reorder) of the recommendations engine rebuild. Dashboard-only. Lite is unchanged (advise/copy-paste only; it renders advice from the fact-key and needs no persisted action). Does NOT build the Recommendations UI (WS1) or new config facts (WS3).

The (later) Recommendations surface must show Apply + the two-sided consent gate for findings read back from storage. The remediation builders REQUIRE finding.DrillDown, but GetRecentFindingsAsync returns findings with DrillDown == null. So — exactly as the existing Alert path does with ContextJson — we persist the BUILT RemediationAction on the finding row and deserialize it on read. (D7, already merged, ensures config/RCSI findings carry their config_issues drill-down below severity 0.5 so their actions can be built.)

File:line changelog

  • PerformanceMonitor.Notifications/AlertContext.cs (+36): added public AlertContextSerializer.SerializeAction(RemediationAction?) (:169) and DeserializeAction(string?) (:184) that wrap the EXISTING private ToDto/FromDto (the proven round-trip for RcsiInactionFigures/ClearPlanFigures/all target lists). DeserializeAction is null/try-catch on bad json, mirroring TryDeserialize.
  • PerformanceMonitor.Analysis/AnalysisModels.cs (+14): AnalysisFinding.Remediation (:126) — ephemeral like DrillDown (built post-enrich on write, deserialized on read; not a scored field). RemediationAction is in the same assembly.
  • Dashboard/Analysis/SqlServerFindingStore.cs (net +63):
    • remediation_action_json nvarchar(max) NULL added to the canonical CREATE TABLE (:59) AND an idempotent IF COL_LENGTH(...) IS NULL ALTER TABLE ... ADD migration in EnsureTablesExistAsync (:88-92). App-managed schema — nothing added to install/ or upgrades/.
    • Split SaveFindingsAsyncFilterMutedFindingsAsync (:106, mute-filter survivors, NO insert) + InsertFindingsAsync (:171, batched insert persisting the action json). Mute/dedup/ordering + _nextId sequencing identical; both keep PR-1's single-connection + single-EnsureTablesExistAsync discipline.
    • InsertFindingAsync (:404-438): added the column/param; persists AlertContextSerializer.SerializeAction(finding.Remediation).
    • GetRecentFindingsAsync SELECT (:216) + ReadFinding (:451): selects/deserializes ordinal 18, field-count-guarded (reader.FieldCount > 18) so GetLatestFindingsAsync (which does not select it) is unaffected.
  • Dashboard/Analysis/AnalysisService.cs (net +35): AnalyzeAsync reorder (:145-175) — filter survivors → enrich survivors → build+attach RemediationAction (try BuildAction ?? BuildRcsiAction ?? BuildClearPlanAction) → batched insert. The returned/enriched (now action-bearing) list still flows to AnalysisCompleted + (via the scheduler) NotifyAsync unchanged.
  • Dashboard/Services/AnalysisScheduler.cs (+1/-1): stale SaveFindingsAsync comment reference updated (no behavior change; notify path verified intact at :193-198).
  • Dashboard.Tests/AnalysisNotificationTests.cs (+93): 4 new tests (below).

Tests (real results)

  • dotnet build PerformanceMonitor.sln -c Debug0 errors (1 pre-existing unrelated CS0649 in RemediationTests.cs).
  • Dashboard.Tests: 238 passed / 0 failed / 0 skipped.
  • Lite.Tests: 360 passed / 0 failed / 0 skipped.
  • New tests (all green, confirmed present via --list-tests):
    • SerializeAction_RcsiAction_RoundTripsFiguresAndTargets — RCSI action through SerializeAction/DeserializeAction; asserts RcsiInactionFigures (BlockingEvents/Deadlocks/ReaderWriterPct) survive.
    • SerializeAction_ClearPlanAction_RoundTripsFiguresAndTargets — clear-plan action; asserts ClearPlanFigures (CpuPercent/AnomalyRatio) survive.
    • SerializeAction_NullAction_SerializesAndDeserializesToNull — null/empty/garbage json → null, no throw.
    • PersistedRcsiAction_RendersTwoSidedConsentGate_WithFindingNull (the C1 gating test) — synth RCSI finding (rcsi:false + inaction enrichment) → serialize → deserialize → FactRiskDisclosure.GetForAction(action, finding: null) returns a non-null two-sided RiskDisclosure with BOTH RisksOfChanging and RisksOfNotChanging non-empty AND the original inaction figures present ("12 blocked-process events", "3 deadlocks", "80%", "RCSI eliminates"). Proves the consent gate renders from the persisted action alone — exactly how the real Apply surface calls it.

Needs integration verification (not unit-testable)

config.analysis_findings is SQL Server; there is no in-memory harness for the store, so the following require a real DB and are NOT claimed as covered by unit tests:

  1. Full persist → reload round-trip: run AnalyzeAsync against a server with an RCSI-off / config finding; confirm a row is written with non-null remediation_action_json, then GetRecentFindingsAsync reads it back with finding.Remediation != null and the consent gate renders.
  2. Idempotent migration on a pre-existing DB: against a config.analysis_findings created before this change, confirm EnsureTablesExistAsync adds the column once and is a no-op on subsequent runs (and that fresh-create DBs already have it, so the ALTER is skipped).
  3. Notify path end-to-end with AnalysisNotificationsEnabled on: confirm NotifyAsync still receives the enriched findings after the reorder.
  4. Cost at the default-on 30-min interval across N servers (one extra connection-open per cycle from the filter/insert split).

🤖 Generated with Claude Code

Dashboard-only. Lets the (future) Recommendations surface drive Apply +
the two-sided consent gate from findings read back from storage, by
persisting the BUILT RemediationAction (the artifact the builders need),
mirroring the alert path's ContextJson. Lite is unchanged (advise/copy-
paste only).

P2 reorder (AnalysisService.AnalyzeAsync): mute-filter survivors -> enrich
survivors -> build + attach each finding's RemediationAction -> batched
insert with remediation_action_json. Muted/absolution findings are no
longer enriched-then-discarded. The returned/enriched list still flows to
AnalysisCompleted + NotifyAsync unchanged.

D2:
- AlertContextSerializer: add public SerializeAction/DeserializeAction
  wrapping the existing private ToDto/FromDto (identical round-trip to the
  alert path; null/try-catch on bad json).
- AnalysisFinding: add ephemeral Remediation (built on write, deserialized
  on read; not a scored field).
- config.analysis_findings: add remediation_action_json nvarchar(max) NULL
  to the canonical CREATE TABLE + an idempotent COL_LENGTH-guarded ALTER in
  EnsureTablesExistAsync (app-managed schema; not install/ or upgrades/).
- SqlServerFindingStore: split SaveFindingsAsync into FilterMutedFindingsAsync
  (no insert) + InsertFindingsAsync (batched, persists the action json);
  GetRecentFindingsAsync selects + deserializes the column (field-count
  guarded so GetLatestFindingsAsync is unaffected).

Tests (Dashboard.Tests/AnalysisNotificationTests.cs): RCSI + clear-plan
action round-trips through the new seam (figures survive); null/garbage
degrade to null; and the C1 gating test — a persisted RCSI action renders a
non-null two-sided RiskDisclosure via GetForAction(action, finding:null)
with the original inaction figures, proving the consent gate works from the
stored action alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@erikdarlingdata erikdarlingdata merged commit 1896af6 into dev Jun 5, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant