Skip to content

Add auto force-plan bot phase 2: the write path (#2138) - #2731

Closed
erikdarlingdata wants to merge 7 commits into
devfrom
feat/2138-force-plan-bot
Closed

Add auto force-plan bot phase 2: the write path (#2138)#2731
erikdarlingdata wants to merge 7 commits into
devfrom
feat/2138-force-plan-bot

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Carved. #2745 is PR A and is open for review; this PR is now PR B — the write path, and nothing else.

#2745Add force-plan bot phase 1: detection, evidence journal, dry-run — took detection, the evidence journal, the V107 rung, the policy and self-review state machines, the config gates and the dry-run ledger. It ships IPlanForceExecutor as a declared seam with no implementation, and pins that three ways (byte-scan of the compiled service assembly for sp_query_store_force_plan / sp_query_store_unforce_plan / FREEPROCCACHE; no concrete implementer in any shipped assembly; no executor-typed member on PlanForceBot). So PR A reviews as a pure detection/audit feature that cannot reach a monitored server.

This branch is not rebased yet and its diff below still shows both halves. Once #2745 lands I will rebase onto dev, at which point what remains here is the write path alone:

  • SqlServerPlanForceExecutorsp_query_store_force_plan / _unforce_plan in the Collect replica_group_id so a force-plan recommendation can be replica-scoped, not just replica-labelled #1914-verified named two-argument form, the read-only verify, and the targeted DBCC FREEPROCCACHE(plan_handle) evict lever
  • the bot's force arm (intent row → executor → completion row linked by related_action_id)
  • the bot's review-unforce arm, including the write-gate RE-CHECK that journals a verdict as withheld when a gate closed after the force, and the review half deliberately outliving the Enabled switch so disarming cannot orphan an outstanding force
  • the consumer of PgPlanForceActionStore.GetPendingReviewsAsync (the read itself, its orphaned-intent grace window and its live-PostgreSQL spec already landed with PR A's journal)
  • PlanForceExecutorTests and the four write-path PlanForceBotTests scenarios

The three no-write pins in PR A go red when this lands, by design. They assert "the write path is not in this build" — a claim that is supposed to stop being true here, in the diff whose whole subject is making it stop being true, so relaxing them is a reviewable act rather than a silent side effect. Do not loosen them for any other reason.

Small enough after the rebase to review character by character, which is what the first write path to production servers deserves.


Draft, per your call that this is too much for a single pass. The honest headline first: the live write path IS in this diffsp_query_store_force_plan / _unforce_plan / targeted DBCC FREEPROCCACHE(plan_handle) all exist in PlanForceExecutor.cs, complete and pinned, reachable only behind two gates that both ship closed.

How to review this (the risk lives in 5 files, in this order)

  1. PerformanceMonitor.Analysis/ForcePlanBotPolicy.cs (256 LOC) — the decision table. (Landed in Add force-plan bot phase 1: detection, evidence journal, dry-run (#2138) #2745.) Invariants pinned by ForcePlanBotPolicyTests: shipped defaults are OFF + DRY RUN; a target with any ForcePlanBlockers output (PSP, secondary-replica evidence) can never evaluate Force or WouldForce whatever the gates say — the #2138 gap 3: parameter-sensitivity co-fire flag + force-plan caution #2140 never-auto-force rule as a testable contract, same function agents read in structured_remediation (#2138: machine-first structured_remediation for MCP consumers #2146); a live Force requires dry-run off AND per-server opt-in, each closed gate named in the journaled reasons; failure memory is a sliding window with no reset API ([BUG] 3.5.0->3.6.0 Deadlocks #2677).
  2. Darling/.../PlanForceExecutor.cs (286 LOC) — the whole subject of this PR after the rebase. The ONLY statements that can ever reach a monitored server. PlanForceExecutorTests pins them character-exact: the Collect replica_group_id so a force-plan recommendation can be replica-scoped, not just replica-labelled #1914-verified named two-argument force form (no @replica_group_id, no @disable_optimized_plan_forcing), no USE/CONVERT anywhere, evict is the bounded handle form — the argumentless cache-wide FREEPROCCACHE is pinned ABSENT, and a garbage plan-hash refuses to parse rather than evicting by accident.
  3. Darling/.../PlanForceBot.cs — the orchestration, where a write would escape. The evaluate-and-journal half landed in Add force-plan bot phase 1: detection, evidence journal, dry-run (#2138) #2745; the force and review-unforce arms stay here. PlanForceBotTests' write-path scenarios pin: intent-row-then-completion-row journaling linked by related_action_id; and the write-gate RE-CHECK on unforce — a self-review verdict journals as withheld, executor untouched, when ANY gate (including the global Enabled) closed after the force. The review half deliberately outlives the Enabled switch: disarming the bot cannot orphan a force it placed while armed.
  4. Darling/.../PgPlanForceActionStore.cs (landed in Add force-plan bot phase 1: detection, evidence journal, dry-run (#2138) #2745) — the windowed history reads ARE the cooldowns. PlanForceActionStoreTests (live PG, CI-gated) pins: an intent+completion force pair spends ONE budget slot; failure memory heals purely by the window sliding; a pending review is closed only by a related review/unforce row; an ORPHANED INTENT (a force that landed but lost its completion journal write) surfaces for review after a 10-minute grace, so no live force can escape review through a store blip; the fleet-wide null-server read works.
  5. PerformanceMonitor.Analysis/ForcePlanSelfReview.cs (landed in Add force-plan bot phase 1: detection, evidence journal, dry-run (#2138) #2745) — the review verdict table; ForcePlanSelfReviewTests is its complete spec (not-net-benefit unforces at the FIRST due checkpoint; exactly-at-the-bar keeps; a quiet query keeps its force with the reason saying the keep rests on absence of evidence; "no longer forced" outranks "failing to force").

Phased plan (what's here vs. what follows)

Phase Content Where
1a Detection consumption, evidence journal, dry-run/shadow mode, cooldowns + budgets #2745
1b The write path + self-review execution, armed only by both gates this PR
2 Evict-first orchestration (evict → observe 25 execs/30 min → force only if the bad plan returns) — the executor lever + tests already exist follow-up
2 MCP/web read surface get_plan_force_actions (web-parity pin touches DarlingWebEndpoints.cs, owned by the #2710 lane right now) follow-up
2 Viewer/settings surface for the opt-in + control-plane knobs; shadow scoring of aged would_force rows (self-healed / still regressing / human forced); alert on live bot action follow-up
3 Lite twin, if an interactive SKU should ever act autonomously at all discussion

Numbers (pre-carve)

21 files, 2,866 insertions / 12 deletions. Production code ~1,600 LOC across 8 files; tests ~1,430 LOC across 6. Verified: full ladder V1→V107 + journal round-trip green on live PostgreSQL 17; all 50 policy/review/orchestrator scenarios executed green on macOS against the real assemblies (the Windows suites are CI's). Renumbered V106→V107 mid-flight after #2719 merged claiming V106; #2745 re-derived the same 107 as max(dev) + 1 and keeps it. CI rounds 1-2 caught five registration ratchets (probe reader ordinal, round-trip catalog, role ACL + its provision-script mirror, the #1902 cleanup pattern) and the review bot five real findings across three rounds (stale V107 comment; evict no-op misclassified as failure; missing orchestrator spec; disarming the bot orphaned outstanding forces from self-review; a force that lost its completion journal write became un-reviewable) - all addressed in follow-up commits, no force-pushes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg

erikdarlingdata and others added 4 commits August 31, 2026 20:14
…2138)

Pure and clockless, in the shared Analysis assembly: ForcePlanBotPolicy
consumes FactRemediation.ForcePlanBlockers' own output (the #2146 contract
- one gate for agents and bot alike), layers the bot's gates on top
(regression floor, per-query cooldown, windowed failed-force memory,
per-server daily budget), and only returns Force when BOTH write gates -
global dry-run off AND the per-server opt-in - are open. Defaults are
globally OFF, then DRY RUN.

ForcePlanSelfReview re-judges a live force at +1h/+24h against the
journaled baseline and unforces when it is not at least 25% better or
fails to apply. Give-up state is a sliding window the caller's read
computes, never a flag (#2677).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
…2138)

collect.plan_force_actions is the bot's append-only audit trail - every
would-force, blocked, force, review, and unforce row with its evidence,
outcomes as their own rows via related_action_id, deliberately outside
retention. config_monitored_servers.plan_force_bot_enabled is write-gate
2, NOT NULL DEFAULT FALSE on every existing and future row.

Viewer probe: table-existence sentinel + newest-first arm, and the
reflection-built all-true pin tracks the new parameter by arity.

Ladder applied cleanly on a live PostgreSQL 17, rung re-run
proven idempotent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
…2138)

The bot rides the SCHEDULED analysis pass only (analyze_now passes a null
hook so an operator poking a server cannot spend the blast-radius budget),
re-judges the pass's PLAN_REGRESSION targets through the shared blocker
gate, journals every decision, and reviews its own outstanding live forces
first - own-forces-only structurally, because the review read starts from
rows this bot journaled.

SqlServerPlanForceExecutor is the ONLY route to a monitored server: the
#1914-verified named two-argument force/unforce, a verify read that
grades the WHOLE query since the force (weighted vs the journaled
baseline), and the targeted DBCC FREEPROCCACHE(plan_handle) evict lever
(phase 2 orchestration). Values travel as bound parameters; the database
as ChangeDatabase.

Config: forcePlanBot section (file-level, OFF then DRY RUN) documented in
the sample; the per-server opt-in column is store-only on purpose - the
registry is authoritative after seeding, so a darling.json knob would be
a silent no-op (#2254), which is disqualifying for a write authorization.

Seed INSERT and registry SELECT alignment proven against a live V106
store (opt-in seeds FALSE; is_enabled TRUE).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
#2138)

The two contracts that must never regress, as tests: a blocked target -
parameter sensitivity above all - can never evaluate Force or WouldForce
whatever the gates say, and a live Force requires BOTH write gates with
each closed gate NAMED in the journaled reasons. Failed-force memory is
pinned healing by the window sliding, with no reset call in the API.

The executor pins are character-exact - the argument forms #1914 measured,
the divide guard, the bounded evict lookup, and the absence of the
argumentless FREEPROCCACHE that would clear a whole cache.

The store round-trip (gated on DARLING_TEST_PG) proves the windowed
history reads, the intent/completion budget single-count, the pending-
review linkage, and the fleet-wide null-server read against a real
store. Plus the CHANGELOG entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
the DBNull guard is for a store mid-migration rather than an expected path — and a null there
falls back to the derivation, which is exactly what it did before this column was read at all. */
StoredServerId = reader.IsDBNull(15) ? null : reader.GetInt32(15),
/* V106 (#2138): write-gate 2 for the force-plan bot. NOT NULL DEFAULT FALSE in the table, so the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment says "V106" but plan_force_bot_enabled is actually added by migration V107 (PgMigrations.cs: new Migration(107, "plan-force-actions", V107Sql), and StorageVersion.SchemaVersion = 107). V106 is the unrelated pg-cpu-utilization rung. Given this codebase's stated convention that comments carry accurate provenance (issue numbers, measurements, version numbers) for future readers, this stale/incorrect version reference should be fixed to V107.

{
/* Nothing cached under that hash is a legitimate outcome (the plan aged out on its
own), not a failure — the journal records it and the caller decides what it means. */
return PlanForceExecutionResult.Failed("no cached plans matched the hash — nothing to evict");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This contradicts the comment immediately above it (lines 165-168): "Nothing cached under that hash is a legitimate outcome (the plan aged out on its own), not a failure — the journal records it and the caller decides what it means." But the code returns PlanForceExecutionResult.Failed(...), which PlanForceBot's eventual phase-2 caller would journal with Outcome = OutcomeFailed. That outcome string is exactly what GetQueryHistoryAsync's recent_failed aggregate counts (pfa.action = 'force' AND pfa.outcome = 'failed'), so once phase 2 wires EvictPlanAsync into the bot's flow, a query whose bad plan simply aged out of cache — a good outcome — would count toward FailedForceThreshold and could cool the query down for FailedForceCooldownHours. This method is unwired/dead code today (no caller in PlanForceBot.cs), so it's latent rather than live, but worth fixing before phase 2 lands so it doesn't ship with an incorrect classification baked into the executor's contract.

/// <para>Failure-isolated at every seam: a journal or executor fault logs and moves on — the bot
/// must never take an analysis pass or a sweep down with it.</para>
/// </summary>
public sealed class PlanForceBot

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test-coverage gap: ForcePlanBotPolicy, ForcePlanSelfReview, PgPlanForceActionStore, and SqlServerPlanForceExecutor all have dedicated test files, but PlanForceBot itself — the orchestrator that wires them together — has none (Darling.Tests/ has no PlanForceBotTests.cs). This class carries logic that isn't exercised elsewhere, e.g.:

  • EvaluateTargetsAsync's MaxTargetsPerPass cap and the seen dedup-across-findings set,
  • the intent-row/completion-row sequencing and relatedActionId wiring in ExecuteForceAsync,
  • and, most importantly, the write-gate re-check in ExecuteUnforceAsync (gatesOpen = !_settings.DryRun && currentConfig.PlanForceBotEnabled) that decides whether a self-review's Unforce verdict actually reaches the server or gets journaled as withheld.

Given this is explicitly called out as "the FIRST feature that can WRITE to a monitored SQL Server," a fake IPlanForceExecutor/PgPlanForceActionStore-backed test of this orchestration (particularly the withheld-vs-executed unforce branch) seems worth having before phase 2 raises the stakes further.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed the phase-1 force-plan bot (policy, self-review, journal store, executor, worker wiring, and migrations). This is a large, carefully-guarded change and the core decision logic is thoroughly pinned by tests (ForcePlanBotPolicyTests, ForcePlanSelfReviewTests, PlanForceExecutorTests, PlanForceActionStoreTests). I traced the two-gate write authorization, the append-only journal/budget/cooldown SQL (including the intent-row/completion-row double-count guard), the never-auto-force PSP/secondary-replica contract, and the self-review net-benefit math — all consistent and correctly ordered against their tests.

Left 3 inline comments:

  • A stale "V106" reference in a StoreConfigProvider.cs comment — the column is actually added by migration V107.
  • PlanForceExecutor.EvictPlanAsync returns Failed for "no cached plans matched the hash," directly contradicting its own comment that this is a legitimate non-failure outcome. It's unwired today (phase 2 work), but will misclassify a benign outcome as a failed-force once wired into the bot's failure-memory counting.
  • PlanForceBot.cs — the orchestrator tying policy + store + executor together, including the write-gate re-check that gates a self-review Unforce from actually reaching the server — has no dedicated unit tests, unlike every other piece of this feature.

No SQL injection concerns (Npgsql/SqlClient calls are all parameterized; the database name travels via ChangeDatabase, never concatenated), no missing-index-DMV suggestions, and I didn't find Lite/Darling parity drift — this is new Darling-only service infrastructure with no Lite-side equivalent to keep in sync (the shared PerformanceMonitor.Analysis policy code Lite also references, FactRemediation.ForcePlanBlockers, is unchanged by this PR).

@erikdarlingdata
erikdarlingdata marked this pull request as draft August 31, 2026 19:27
The four registration points the recipe grep missed, each caught by its
own ratchet: the probe reader now consumes ordinal 82 (the five
three-places-agree pins); PlanForceBotEnabled joins the round-trip
catalog; plan_force_bot_enabled is classified NON-SECRET in
DarlingManagedRoles (an arm/disarm state as sensitive as is_enabled -
the viewer must show which servers are armed for the opt-in to be
auditable); and the store test's teardown goes through LiveStoreCleanup
(#1902) instead of hand-opened connections.

Review findings: the stale V106 comment in BuildServerFromRow says V107;
an evict that finds nothing cached returns NoOp, not Failed - the plan
aging out is the GOAL state, and a failed outcome would have fed the
failure-memory window and cooled a query down for succeeding once
phase 2 wires eviction in.

The coverage catch: PlanForceBot itself now has a spec.
IPlanForceActionStore is extracted so the orchestrator runs over an
in-memory journal and a recording executor, and PlanForceBotTests pins
the seams no other suite can see - the engine gate at the connection
boundary, cross-finding dedup + the per-pass cap, intent-then-completion
linkage, and the write-gate RE-CHECK on the unforce path (a self-review
verdict journals as withheld, executor untouched, when either gate
closed after the force). All eleven scenarios also executed green on
macOS against the real assemblies via the net10.0 harness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
Comment on lines +73 to +76
if (!_settings.Enabled || runtime is null || currentConfig is null)
{
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flipping the global Enabled gate off doesn't just stop new evaluations — it also skips ReviewPendingForcesAsync entirely, since that call sits behind this same early return and only runs from RunAfterAnalysisAsync. That means any plan the bot already force-pinned live stays forced with no further self-review once an operator sets forcePlanBot.enabled: false (and restarts, since this is file-level and not hot-reloaded).

Contrast this with the other two gates, which both keep reviewing on purpose:

  • DryRun = true still runs ReviewPendingForcesAsync and journals a "withheld" unforce with a by-hand EXEC sys.sp_query_store_unforce_plan hint (ExecuteUnforceAsync, line ~304-322).
  • Per-server PlanForceBotEnabled = false hits the same withheld path.

So the one lever an operator is most likely to reach for after discovering a bad force — "turn the whole bot off" — is the one lever that silently disables the safety net (self-review / auto-unforce) for a plan it already pinned on a production server, with nothing journaled or logged to say review has stopped. Given how carefully this PR treats every other "gate closed after a force" case (always journaling a withheld verdict with a manual-fix hint), this asymmetry looks like an oversight rather than intent.

Worth considering: let ReviewPendingForcesAsync run (or at least the read-only VerifyAsync + a withheld-unforce journal entry) even when _settings.Enabled is false, or explicitly document that enabled: false abandons outstanding live forces and operators must use dryRun: true instead to keep the safety net active.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed the diff (policy, self-review, executor, orchestrator, journal store, migration, config/registry wiring, tests) against CONTRIBUTING's T-SQL/PostgreSQL conventions, Lite/Darling parity, and security/correctness at the write boundary.

Overall: this is unusually careful work for a first write path to production SQL Servers. The two-gate contract (global dryRun + per-server opt-in) is enforced in one place (ForcePlanBotPolicy.Evaluate) and re-checked independently on the unforce path; every write statement lives in PlanForceExecutor.cs as a pinned constant with character-exact tests (no USE, no CONVERT, no argumentless DBCC FREEPROCCACHE, database travels via ChangeDatabase not string concatenation); the journal is append-only with intent-then-completion rows so a crash mid-write still leaves a trail; failure memory and cooldowns are windowed reads rather than latched flags (the #2677 lesson applied consistently). All PostgreSQL access is parameterized — no injection surface found. Migration is schema-qualified per the PostgreSQL-store convention, and the schema-version probe/ratchet, registry round-trip test, and role classification were all updated together.

One correctness/safety gap (posted inline on PlanForceBot.cs): the global forcePlanBot.enabled: false kill switch also silently disables ReviewPendingForcesAsync, so a plan the bot already force-pinned live gets no further self-review once the bot is fully disabled — unlike flipping dryRun back to true or revoking the per-server opt-in, both of which keep reviewing and journal a "withheld" verdict with a manual-unforce hint. The one lever most likely to get pulled after a bad force (turn the whole thing off) is the one that abandons the safety net for whatever it already forced, with nothing logged to say so.

Lite/Darling parity: no drift — this is explicitly a Darling-only feature (phase 3 in the PR description defers "Lite twin, if ever" as a discussion item), and the two new files in the shared PerformanceMonitor.Analysis assembly are inert dead code from Lite's perspective (grepped, no references). No parity violation.

Style: collector-style queries (VerifySql, EvictHandleLookupSql) correctly carry OPTION(RECOMPILE), AS aliases, and column_name = expression aliasing; the one-shot EXEC sp_query_store_force/unforce_plan statements reasonably omit it since there's no parameter-sensitivity concern for a fixed two-parameter system-proc call. Migration DDL is schema-qualified and uses unabbreviated types.

Nothing else rose to the level of a blocking finding — the test suites (ForcePlanBotPolicyTests, ForcePlanSelfReviewTests, PlanForceBotTests, PlanForceExecutorTests, PlanForceActionStoreTests) already pin most of the edge cases I went looking for (NaN/Infinity regression factors are guarded upstream by NULLIF in the detection SQL, not just in the bot).

….sql (#2138)

The #1639 drift ratchet working as designed: the operator-facing
provisioning script's GRANT SELECT column carve is a hand mirror of
DarlingManagedRoles.ViewerRestrictedConfigTables, and
ProvisionRolesAclDriftTests parses the script and asserts set-equality -
so classifying the column in C# alone failed both CI jobs until the
script named it too. Non-secret, exactly like is_enabled beside it.
Comment kept parenthesis-free inside the GRANT list on purpose: the
parser strips -- comments before matching, but the paren-scan that finds
the list's end does not need the extra test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
Comment on lines +191 to +197
var executor = _executorFactory(runtime.ConnectionString);
var result = await executor.ForcePlanAsync(target.Database, target.QueryId, target.PlanId, ct);

await _store.JournalAsync(BuildRecord(
runtime, target, PgPlanForceActionStore.ActionForce, Array.Empty<string>(),
result.Succeeded ? PgPlanForceActionStore.OutcomeSucceeded : PgPlanForceActionStore.OutcomeFailed,
detail: result.Error, relatedActionId: intentId, DateTime.UtcNow), ct);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: a force that succeeds on the server can become permanently un-reviewable if the completion journal write fails.

ExecuteForceAsync writes the intent row, calls executor.ForcePlanAsync (which really executes sp_query_store_force_plan on the monitored server), and then writes the completion row. If ForcePlanAsync succeeds but this second JournalAsync call throws (e.g. a transient Postgres blip unrelated to the SQL Server connection that just succeeded), the exception propagates out of EvaluateTargetsAsync and is swallowed by the outer try/catch in RunAfterAnalysisAsync (line 98-107) — logged as a warning and nothing else.

The consequence: the plan is forced on the monitored server, but no row with outcome = 'succeeded' and action = 'force' ever lands in collect.plan_force_actions. GetPendingReviewsAsync (PgPlanForceActionStore.cs) only surfaces rows matching exactly that shape, so this force can never appear as "pending review" — ReviewPendingForcesAsync will never call VerifyAsync/ForcePlanSelfReview.Evaluate for it, and the self-review "pays rent or gets taken back" guarantee that's the centerpiece of this PR's safety story silently never applies to it. It also isn't retried: nothing re-attempts the completion journal write on a later pass.

Compare with the analogous unforce path (ExecuteUnforceAsync): if its completion write fails after a successful UnforcePlanAsync, the row still self-heals next pass, because the pending-review row from the original force is still there, and the next VerifyAsync will observe PlanIsStillForced == false and close it out via a review row. The force path has no equivalent fallback because the "pending" status is established by the very completion write that's at risk.

Worth considering: write the completion row in a retry loop (or at least a couple of attempts) before giving up, or have a periodic reconciliation pass that lists forced plans on the server (or a way to detect intent rows stuck in attempting past some age) and treats them as pending review. As shipped, this is a narrow race window, but it directly undermines the one invariant ("a force that buys nothing still pins a plan against future data change, so it has to pay rent") that the rest of the design is built around.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed the diff (2,773 insertions across 21 files: ForcePlanBotPolicy.cs, ForcePlanSelfReview.cs, PlanForceExecutor.cs, PlanForceBot.cs, PgPlanForceActionStore.cs, the V107 migration, config/role wiring, and tests). Followed the "how to review this" map in the PR description.

Overall: this is unusually well-defended code for a first write path. The SQL surface is fully parameterized (no string concatenation anywhere — database name travels via ChangeDatabaseAsync, plan hash via a parsed binary(8) parameter, never a CONVERT), the two write gates (dryRun + per-server plan_force_bot_enabled) are checked independently and both named in the journal reasons, ForcePlanBotPolicy.Evaluate and ForcePlanSelfReview.Evaluate are pure/clockless and match their test suites exactly, and FactRemediation.ForcePlanBlockers is genuinely shared (not re-derived) between the MCP surface and the bot, so "advise" and "act" can't drift. T-SQL in PlanForceExecutor.cs follows house style (AS aliases, column_name = expression, OPTION(RECOMPILE) on both VerifySql statements and the evict lookup). The V107 migration schema-qualifies both objects (collect.plan_force_actions, config.config_monitored_servers) per the Postgres convention in CONTRIBUTING.md. Role exposure (DarlingManagedRoles.cs) and the provisioning script (provision-roles.sql) were both updated for the new column, so there's no drift between the two.

One correctness finding posted inline on PlanForceBot.cs (ExecuteForceAsync): if the live force succeeds on the monitored server but the second journal write (the completion row) fails, the action becomes permanently invisible to GetPendingReviewsAsync — which requires an outcome = 'succeeded' force row to treat something as pending review — so the self-review "pay rent or get taken back" mechanism silently never runs for that force. The mirror-image unforce path self-heals from the same failure via the next pass's VerifyAsync read; the force path doesn't have an equivalent fallback because the "pending" status is established by the very write that's at risk.

Lite/Darling parity: no Lite changes in this PR, and none are needed — the PR description explicitly scopes this to Darling only (phase 3, "Lite twin, if an interactive SKU should ever act autonomously at all," is left as a future discussion item), and the journal's reasons column is deliberately text rather than text[] specifically so a future Lite/DuckDB twin could share the column shape. No drift to flag here.

Security: no SQL injection surface — every value is a bound parameter on both the SQL Server and Postgres sides. The write path is a single reviewable seam (IPlanForceExecutor) exactly as advertised, gated well before any connection opens (PlanForceBot.RunAfterAnalysisAsync's Postgres-engine short-circuit). No secrets or hardcoded credentials introduced.

Performance: bounded per-pass (MaxTargetsPerPass = 10), one history round-trip per evaluated target (acceptable at that cap), pending-reviews read is LIMIT 16 on an indexed lookup. No missing-index-DMV suggestions here per house rules.

Nothing else rose to the level of a blocking finding — the extensive test suites (ForcePlanBotPolicyTests, ForcePlanSelfReviewTests, PlanForceBotTests, PlanForceExecutorTests, PlanForceActionStoreTests) already pin most of the edge cases I went looking for (cooldown boundary, exactly-at-the-net-benefit-bar, own-forces-only, engine-boundary gating, budget double-counting on intent/completion pairs).

Two categorical gaps in the review loop, both real:

1. Disarming the bot orphaned outstanding live forces: the whole pass sat
behind the Enabled gate, so forcePlanBot.enabled: false left a
bot-pinned plan forced forever with nobody watching. The review half now
deliberately outlives the switch that armed the force - a disabled bot
still verifies its OWN outstanding forces (one indexed read of a journal
that is empty unless this deployment ever forced live), journals the
verdict, and WITHHOLDS the write (gatesOpen now includes Enabled) - one
actionable row + log line per orphaned force, then quiet. Only the
evaluation half is Enabled-gated.

2. A force that landed on the server but lost its completion journal
write (store blip between the two rows) became permanently
un-reviewable: pending reviews keyed on succeeded completion rows only.
The read now also surfaces ORPHANED INTENTS - attempting rows past a
10-minute grace that no completion row references - and the verify read
answers what actually happened: still forced gets a real review, never
landed closes as no_longer_forced.

Both pinned in PlanForceBotTests and the live-store round-trip, and both
executed green on macOS against the real assemblies and a live
PostgreSQL 17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Claude posting for Erik Darling

Closing: divergent second implementation of a phase that already landed, 672 commits behind dev.

Phase 1 reached dev via d8f22c40c ("Wire the force-plan bot into the service, with no write path at all") plus e4f40a0dd. This branch does not contain d8f22c40c, so the two are independent implementations rather than a stack. It conflicts in PlanForceBot.cs, PgPlanForceActionStore.cs, PlanForceBotTests.cs, DarlingConfig.cs, DarlingWorker.cs, StoreConfigProvider.cs and darling.sample.json — its own subject matter. A merge is a rewrite, and it would be a rewrite against a phase-1 that is not the one this branch assumed.

#2138 stays open, because the work this branch aimed at is genuinely outstanding and deliberately fenced. dev carries PlanForceNoWritePathTests, which asserts the absence of sp_query_store_force_plan, sp_query_store_unforce_plan and FREEPROCCACHE — the bot detects and journals, and the write path is held closed on purpose. IPlanForceExecutor.ForcePlanAsync is the seam left for it.

So phase 2 should be rewritten against dev's seam rather than resurrected from here. What is worth reading off this branch before it goes cold, as design input rather than code: 2323d28d3 (policy gate and self-review state machine), 5df09b862 (the bot's decision table, review spec, write-path SQL and journal pins), 9f5748d6e ("a live force can never escape its review"), and 26a610c6e (mirroring the plan_force_bot_enabled grant into provision-roles.sql — an easy thing to forget twice).

Closing by hand; closing keywords do not fire here.

pull Bot pushed a commit to ehtick/PerformanceMonitor that referenced this pull request Sep 10, 2026
…rikdarlingdata#2138)

The orchestrator runs after each SCHEDULED analysis pass (analyze_now
passes a null hook — an operator poking a server should not spend the
bot's blast-radius budget), judges the pass's PLAN_REGRESSION targets
through the shared gate, and journals the verdicts.

It cannot write to a monitored server, and that is asserted three ways
rather than promised. IPlanForceExecutor is declared with NO
implementation; PlanForceBot holds no executor, no connection factory
and no member that names the seam; and the compiled service assembly is
searched byte-wise for sp_query_store_force_plan,
sp_query_store_unforce_plan and FREEPROCCACHE and must contain none.
Proven red by dropping the write path back in: all three go red, and
they go red again when erikdarlingdata#2731 lands it — which is the point. Relaxing
them is the reviewable act, not a side effect.

Open all three gates on this build and the Force verdict journals as
withheld, its own outcome: not 'failed' (nothing failed, and a failed
force would cool the query down for a week) and not 'attempting' (which
would surface as an orphaned intent owed a review of a force that never
happened). Saying so out loud beats a silent downgrade to would-force —
an operator who opened every switch believes the bot is live.

Engine-gated at the boundary a connection would cross, not at the
upstream fact (erikdarlingdata#2213's lesson), and failure-isolated at both seams so a
bot fault can never reclassify a good analysis pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
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