Repository navigation
Agents can recompile a module and create or drop a plan guide through Level 3 server actions, with Undo (#6569) - #6635
Conversation
…rocedure, function or trigger (#6569)
… name, resolved object journaled, trigger grant wording (#6569, review round 1) - A database excluded in the server's entry is refused for a dry run and a real run, before anything is read, with the same sentence as the Azure kill. - The name parameter holds the longest valid two-part name (517 characters); the journal and the answer name the schema and name the server resolved. - The type check and sp_recompile run in one batch on the same object. - The docs and the refusal sentence say a DML trigger needs ALTER on its table, which also allows changing and emptying it. - The undo refusal test makes its own journal row and uses a login that is actually invalid.
…ndo; both plan guide switches in V215 (#6569, slice 2a)
…, live tests run on SQL Server 2019 (part of #6569) An OBJECT guide needs ALTER on the module only and a SQL guide ALTER on the database, for the create and the drop; an Azure SQL Database or Managed Instance target is refused as engine_not_supported; live guide tests corrected where the test, not the batch, was wrong.
…ith an Undo that re-creates the guide (#6569, slice 2b)
… own database, grant text names what each grant allows (#6569) - Both plan guide Undos act only on a journal row an agent action wrote: an agent actor, a request row on the same server, and a Level 3 request. - The re-create Undo rebuilds the definition with PlanGuideDefinition.TryCreate; both Undos check the guide name, refuse an excluded database, and refuse an Azure SQL or PostgreSQL target, recording each refusal. - The SQL guide cached-batch count reads only batches compiled in the guide's own database. - create_plan_guide description says which table hints change results; no hint is refused. - Dispatch doc grant rows say what each ALTER grant also allows; a refusal for a guide on a DML trigger names the trigger's table.
…rt of #6569) TryCreate and the create and drop tools accept OPTION hints, an XML showplan, no hints (NULL) and TEMPLATE guides, so a dropped guide can always be undone. The drop no longer refuses a TEMPLATE guide, and its changed-guide compare treats NULL hints on one side as a change.
…ournal, and the store keeps those rows to the service (#6569)
…x/6569-journal-owner
…ame recompile and plan guide; hints description within its cap (#6569) The investigations arm already served recompile_module, create_plan_guide and drop_plan_guide through InvestigationRules.AgentActionToolNames, the same list force uses. The two served-tool tests still listed thirteen, so they now list sixteen and are named by what they check. The instructions text names the three tools beside force. The create_plan_guide hints parameter description is back under the 230-character cap, and the owner-source pin lists set_alert_threshold_override.
…-set-up stores at start, drop_plan_guide refuses a guide its Undo cannot re-create, the web plan Undo journals on the owner, undo_plan_change names all four kinds (#6569)
| if (!ModuleRecompileStatement.TryNormalize(module_name, out var module)) | ||
| { | ||
| return await RefuseBeforeGateAsync(store, agent_action, tool, RemediationResult.Reasons.BadModuleName, | ||
| "module_name is the two-part schema.name of a procedure, function or trigger, as get_procedure_stats returns it. Nothing was run.", ct); |
There was a problem hiding this comment.
recompile_module's three pre-gate refusals drop agentTokenName, so a named agent token's refusal rows are journaled under the caller-supplied agent_id.
All three RefuseBeforeGateAsync calls in this file end at ct — line 54 (bad_module_name), line 61 (bad_database) and line 69 (postgres_target) — while the method's own agentTokenName parameter is used only once, at line 80 for ServerActionGate.AuthorizeAsync. Every other action tool passes it: DarlingMcpPlanForceTools.cs:425 / :433 (evict), :539 / :546 (kill), and the two new plan guide tools in this same PR (DarlingMcpPlanForceTools.PlanGuide.cs:62, :68, :77, :83, :311, :317, :325, :331).
RefuseBeforeGateAsync (DarlingMcpPlanForceTools.cs:929) journals agentTokenName ?? parsed.AgentId, with the comment "#6542: a named agent token journals under its own name, never the agent_id it passed." Dropping it is the exact shape the #6593 review already ruled on: AgentTokenIdentityGateLiveTests.cs:171-172 says "each kill refusal made before the gate journals the token's name, not the agent_id passed. A case fails when its call site drops agentTokenName: the row then names agent:someone-else."
Failure scenario: a client holding the named agent token triage-bot calls recompile_module with module_name = "dbo" (one part) and agent_id = "someone-else" in its agent_action. The refusal journals actor = 'agent:someone-else' — an identity the caller chose — instead of agent:triage-bot. The same call to kill_session, evict_cached_plan, create_plan_guide or drop_plan_guide journals the token's name.
Uncovered: AgentTokenIdentityGateLiveTests rosters only the kill refusals and create_mute_rule; there is no recompile_module case, and no census walks RefuseBeforeGateAsync call sites.
| "module_name is the two-part schema.name of a procedure, function or trigger, as get_procedure_stats returns it. Nothing was run.", ct); | |
| "module_name is the two-part schema.name of a procedure, function or trigger, as get_procedure_stats returns it. Nothing was run.", ct, agentTokenName); |
(and the same addition at lines 61 and 69)
|
|
||
| /* A login without ALTER on the database does not see a SQL guide in sys.plan_guides (catalog views show a login what it holds a permission on), so for it a guide that | ||
| is not found may be one it cannot see: say that, with the permission sentence, instead of claiming the guide is gone. */ | ||
| if (!found && !grantedDb) |
There was a problem hiding this comment.
The "you cannot see it" refusal fires for an OBJECT guide too, where it is not true — and it tells the operator to widen a grant the action does not need.
The branch is unconditional on guide type, but the sentence it returns is written for a SQL guide only: "it does not hold ALTER on the database {database}, which sp_control_plan_guide needs for a SQL guide, and a SQL guide is not listed for a login without it."
For an OBJECT guide the premise is false. sys.plan_guides lists an OBJECT guide to a login that holds ALTER on the referenced object, which is exactly the least-privilege configuration this PR documents (docs/darling-agent-dispatch.md grants row) and tests (PlanForceToolLiveTests.PlanGuide.cs:754 ALoginWithAlterOnTheProcedureOnly_...). In the batch, @can_db is HAS_PERMS_BY_NAME(DB_NAME(), N'DATABASE', N'ALTER') = 0 for such a login, and the SET @can_db = 1 compensation at line 206 only runs inside IF @found = 1, so a guide that is genuinely gone leaves found = 0, grantedDb = 0 and lands here.
Failure scenario: an agent creates an OBJECT guide g_p on dbo.p under a remediation login granted ALTER ON OBJECT::dbo.p and nothing else; a DBA then drops the guide by hand; a person clicks Undo in Agent changes. The undo answers refused / guide_permission_denied with "the remediation login cannot see it: it does not hold ALTER on the database", instead of guide_not_found / "it is already gone". The operator's remedy, as worded, is to grant database-wide ALTER — which docs/darling-agent-dispatch.md itself warns "also lets the login create users, change roles and schemas, and add database DDL triggers".
The information to tell the two apart is already in hand on the create-undo path: DropPlanGuideAsync receives expected, whose Type is the journaled "OBJECT". Gating this branch on expected?.Type is not "OBJECT" (and, for the drop action, keeping it as-is since the type is unknown when the guide is not found) would keep the SQL-guide case and give the OBJECT case the true answer.
Uncovered: TheUndoOfAGuideThatIsAlreadyGone_SaysSo_AndChangesNothing_... (PlanForceToolLiveTests.PlanGuide.cs:545) runs as db_owner, so grantedDb is 1 there, and ALoginWithAlterOnTheProcedureOnly_... never drops the guide out from under the undo. The PR body's round-2b note says "A login with the grant still gets guide_not_found. The same applies to 2a's undo of a create for such a login (it had no test)" — it does not say that for an OBJECT guide the sentence names a grant that is not the one sp_control_plan_guide needs.
Non-blocking notes (not part of the verdict)1. The three new Level 3 tools skip the #6455 switch-state sentence on a refused dry run. 2. 3. Two test files gained a UTF-8 byte order mark that the body does not mention. 4. The V218 migration's name undersells it. |
There was a problem hiding this comment.
Verdict: CHANGES REQUESTED
Claims
- VERIFIED - every value reaches
sp_create_plan_guideas a parameter, none spliced into batch text -SqlServerRemediationExecutor.PlanGuide.cs:250-257; only the fixed type list is interpolated (:163); testAStatementWithAQuote_GoesThroughAsAParameter_AndIsStoredExactly. - VERIFIED - the recompile write is
EXEC sys.sp_recompile @objname = @resolved;in the same batch, name as@p0-SqlServerRemediationExecutor.RecompileModule.cs:33,:45,:81. - VERIFIED - V218 widens
ck_config_server_actions_action, guarded, inserts no row -PgMigrations.cs:2379-2390,:2784;StorageVersion.cs:19= 218. - VERIFIED -
create_plan_guideanddrop_plan_guideride V218's CHECK, no new rung -PgMigrations.cs:2388,InvestigationContracts.cs:349. - VERIFIED - the three agent action events are owner-only in both trigger texts -
InvestigationRequestStore.AgentAction.cs:156-164,DarlingManagedRoles.csJournalOwnerEventList,provision-roles.sql:416. - VERIFIED - every plan tool passes the owner source -
DarlingMcpPlanForceTools.cs:242,:419,:524;...PlanGuide.cs:57,:306;...RecompileModule.cs:48;DarlingMcpAgentActions.cs:148,DarlingMcpSignatureMergeTools.cs:91,DarlingMcpThresholdOverrideTools.cs:88. - VERIFIED - the web plan Undo runs on the owner source while
IsSignedInAdminandCanDecidestay on the web login -DarlingWebEndpoints.cs:1787,:1792,:1906-1909. - VERIFIED -
drop_plan_guiderefuses a guide its Undo could not re-create, dry run and write, after the gate -...PlanGuide.cs:342,:376-383,:387. - VERIFIED - the guide Undo acts only on an agent row (actor
agent:, same server, level 3+) -AgentChangeUndo.PlanUndo.cs:139,:236-237. - VERIFIED - a store with no owner source runs on its own, and the Level 2 tools then fail at
action_called(stated as a tradeoff) -InvestigationRequestStore.cs:250; pinned byAgentLevel2StoreTests.cs:95-97. - UNVERIFIED - all live-rig results and counts (123, 142, 977, 1020, 1034, 1109, 1185) and the six revert-proofs - needs a SQL Server 2019 with a remediation login plus a throwaway PostgreSQL store.
- UNVERIFIED -
sp_recompileworks on Azure SQL Database, not run - needs an Azure target; the code matches (...RecompileModule.cs:66refuses only PostgreSQL,InvestigationContracts.cs:358). - UNVERIFIED - the measured engine facts (no row from
fn_validate_plan_guidewhen valid, message 8698, byte-identical read-back, error 10502) - engine behaviour; the batch at...PlanGuide.cs:166-177rests on them. - REFUTED - "
StorageVersion.SchemaVersion = 215...GetBoolean(190)... 254 tools / 142 unique" (also 255/143, 256/144) - tree has 218,ViewerDataService.cs:1357ends atGetBoolean(193),DarlingMcpInstructions.cs:58reads 264/152. Stale draft lines the Dev merge section corrects, not a code defect. - REFUTED - "a TEMPLATE guide is refused (
guide_template,bad_guide_type)" - TEMPLATE is accepted (...PlanGuide.cs:55,:77-85), dropped and journaled (:413), and noguide_templateexists inRemediationResult.cs:87-95. The "Guide forms" section says it supersedes these lines.
Riskiest lines
Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanForceTools.RecompileModule.cs:54(also:61,:69) - the three pre-gateRefuseBeforeGateAsynccalls dropagentTokenName, so a named agent token's refusal is journaled under the caller-suppliedagent_id;AgentTokenIdentityGateLiveTests.cs:171-172names this exact shape and every sibling tool passes it - UNCOVERED (that test rosters kill only;PlanForceToolLiveTests.Recompile.cs:27passes no token name).Darling/PerformanceMonitor.Darling.Remediation/SqlServerRemediationExecutor.PlanGuide.cs:403- the!found && !grantedDbbranch returns a SQL-guide-only sentence for every type, so the Undo of a gone OBJECT guide under an object-only ALTER login is told to grant database-wideALTERinstead of "already gone" - UNCOVERED (...PlanGuide.cs:545runs asdb_owner;:754never drops the guide first).Darling/PerformanceMonitor.Darling.Storage/InvestigationRequestStore.AgentAction.cs:186-AgentActionSourcefalls back to themcplogin, which the tightened trigger now answers with a raw 42501; unlike the plan tools (...PlanGuide.cs:86-90) the Level 2 store tools have noowner is nullarm - covered as a throw byAgentLevel2StoreTests.cs:95-97and documented in the body, so pinned behaviour, not a finding.
Traps near this diff
- "Lite and Darling ship in parity ... a fix to one SKU's reader, tool, alert or description without the counterpart's is a finding, not a nit" - does not apply - server actions have no Lite twin by design (
PgMigrations.cs:2407), and the one shared file that grew gates the new names onlevel2(InvestigationBriefComposer.cs:232), which Lite never reaches (InvestigationTables.cs:110pinsautonomy_level = 1). - "
hours_back,limit,top,days_backare refused when out of range, never clamped" - does not apply - the new tools take no window or page argument (...PlanGuide.cs:526-543,:551-562,...RecompileModule.cs:172-183); the caps at...PlanGuide.cs:58-64refuse, never truncate. - "Truncation inferred from a full page:
truncated = rows.Count >= limit" - does not apply - no paged read; the one count iscached_matches, taken with no LIMIT (...PlanGuide.cs:152-155). - "A
total_*assigned from a.Countis a count of the page unless the set is provably whole" - does not apply -cached_matchesis the server'sCOUNT_BIG(*)filtered tost.dbid = DB_ID()(...PlanGuide.cs:152-155), andMatchWords(:302-307) says what it is "of". - "A JSON number above 2^53 is rounded by every JavaScript client" - does not apply - the only
longemitted isjournal_id(...PlanGuide.cs:173,:430), a bigint identity from 1, not a tick-scale id; force, evict and kill already ship it. - "Ruling (#5833): tests are load-bearing ... Never add one" - does not apply - no new census; existing rosters are extended (
MigrationDataMovingRungCensusPins.cs:184-187,PlanForceNoWritePathTests.cs:183,:268) and the six new files are live behaviour tests. Its corollary holds above: those source lists are not coverage for either finding. - "
.gitattributessaystext=auto eol=crlf... a diff that touches every line is a line-ending conversion until proven otherwise" - does not apply - the two encoding hunks (DarlingMcpInvestigationToolsTests.cs:1,PlanForceNoWritePathTests.cs:1) touch one line each, a byte order mark before/*.
Divergence (blind read vs PR body)
- The body presents the three new tools as one shape ("same gate as
recompile_module") and devotes a whole section to making the journal name the service rather than a caller, but the diff shipsrecompile_modulewithagentTokenNamedropped at all three pre-gate refusals while both plan guide tools, written later in the same PR, pass it at all eight of theirs. The body does not mention the difference, and #6593 already ruled this shape a defect. - Slice 2b says the not-found permission sentence "applies to 2a's undo of a create for such a login (it had no test)". Extending it there also extends it to OBJECT guides, where the sentence's own premise ("a SQL guide is not listed for a login without it") is false and the type is already known from
expected. The body reads this as a coverage gap; it is also a wrong answer. - Unmentioned in the body: two UTF-8 byte order marks added to test files, and the three new tools using
GateRefusalAnswerinstead of theGateRefusalAnswerAsyncthe other server action tools use, which drops the #6455 switch-state sentence from a refused dry run. Both are in the non-blocking comment.
Everything else agrees: the owner-only journal move, the V218 rung and probe sentinel, the Undo provenance guard, guide_not_undoable, and the web Undo's owner source all read in the tree as described.
Reviewer checklist
- Add a
recompile_modulecase toAgentTokenIdentityGateLiveTestsand read the refusal row's actor before and after passingagentTokenNameat...RecompileModule.cs:54,:61,:69. - On sql2019, narrow the remediation login to
GRANT ALTER ON OBJECT::dbo.p, create an OBJECT guide, drop it by hand assa, click Undo, and read which reason comes back. - Start a service against a store provisioned by the older
provision-roles.sqland confirm the journal function is replaced in the same transaction as the password rules; only a live start reaches the below-V191 arm andRecoverFromReadAsync. - Apply V218 to a store that already holds
config.config_server_actionsrows and confirm the CHECK validates them without a long lock. - Run
recompile_moduleagainst an Azure SQL Database (edition 5), the one new tool that does not refuse that engine. - Re-run the live SQL Server classes on the merged head; the Dev merge section says they were not rerun there.
…BJECT guide that is gone as gone, add the dry-run switch sentence to the new tools, drop two BOMs (#6569)
There was a problem hiding this comment.
Verdict: LGTM
Claims
- VERIFIED —
recompile_module's three refusals before the gate now passagentTokenNametoRefuseBeforeGateAsync, as the kill/evict/plan-guide tools do —Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanForceTools.RecompileModule.cs:54,61,69; testAgentTokenIdentityGateLiveTests.EachRecompileModuleRefusalBeforeTheGate_IsJournaledUnderTheTokensName. - VERIFIED — the
!found && !grantedDbbranch inDropPlanGuideAsyncnow also requiresexpected?.Typenot to beOBJECT, so a gone OBJECT guide answersguide_not_foundinstead of the database-ALTER permission sentence; a drop action (no journaled type) keeps the old answer —Darling/PerformanceMonitor.Darling.Remediation/SqlServerRemediationExecutor.PlanGuide.cs:401-407; testPlanForceToolLiveTests.ALoginWithAlterOnTheProcedureOnly_UndoingAnObjectGuideThatIsAlreadyGone_SaysItIsGone_NotThatTheGuideIsHidden. - VERIFIED —
recompile_module,create_plan_guideanddrop_plan_guidenow callGateRefusalAnswerAsync(...)instead of the bare syncGateRefusalAnswer, matching the three older server action tools —...RecompileModule.cs:82,...PlanGuide.cs:96,...PlanGuide.cs:344. - VERIFIED — the UTF-8 BOMs on
DarlingMcpInvestigationToolsTests.csandPlanForceNoWritePathTests.csare gone, one line changed in each file — confirmed viagit diff --staton both commits (1 insertion/1 deletion apiece). - VERIFIED — the CS8602 possible-null fix in
CreatePlanGuideAsync(permit!.Database) matches the null-forgiving operatorDropPlanGuideAsyncalready used —SqlServerRemediationExecutor.PlanGuide.cs:236(commit316841c4), compared against the unchanged:358. - UNVERIFIED — the live sql2019 check that a login holding only
GRANT ALTER ON OBJECT::dbo.psees an OBJECT guide insys.plan_guideswhile failingHAS_PERMS_BY_NAME(..., 'DATABASE', 'ALTER')— needs a live SQL Server 2019 instance with a narrowed login; the code change is consistent with the claim but the underlying engine behavior can't be checked from a read-only tree.
Riskiest lines
Darling/PerformanceMonitor.Darling.Remediation/SqlServerRemediationExecutor.PlanGuide.cs:405— this is the exact line the prior review flagged as "Riskiest line #2"; wrong behavior here means an agent undoing an OBJECT-guide create under an object-only-ALTER login gets told to grant database ALTER instead of "already gone" — covered byPlanForceToolLiveTests.ALoginWithAlterOnTheProcedureOnly_UndoingAnObjectGuideThatIsAlreadyGone_SaysItIsGone_NotThatTheGuideIsHidden(live, assertsguide_not_found+ "already gone" + absence of the database-ALTER sentence).Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanForceTools.RecompileModule.cs:54(also:61,:69) — this is the prior review's "Riskiest line #1"; droppingagentTokenNamehere journals a named token's refusal under the caller-suppliedagent_idinstead — covered byAgentTokenIdentityGateLiveTests.EachRecompileModuleRefusalBeforeTheGate_IsJournaledUnderTheTokensName(three cases: bad_module_name, bad_database, postgres_target).Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanForceTools.PlanGuide.cs:96,344and...RecompileModule.cs:82— switch toGateRefusalAnswerAsyncso a refused dry run also gets the switch-state sentence — UNCOVERED; the PR body itself says "No existing test pinned the old text," and no new test asserts the sentence's presence for these three tools. Non-blocking (matches the prior review's own non-blocking note #1, not a correctness risk), but worth a one-line note if a future round touches this path again.
Traps near this diff
- "Lite and Darling ship in parity ... a fix to one SKU's reader, tool, alert or description without the counterpart's is a finding, not a nit" — does not apply — server actions have no Lite twin; no
ServerActionGate/AgentActionGatecaller exists under any Lite path (grep -rl ServerActionGate --include=*.cs .returns no Lite files). - "Ruling (#5833): tests are load-bearing ... never add [a test] that reads source text or asserts how code is spelled" — does not apply — the new/extended tests (
AgentTokenIdentityGateLiveTests,PlanForceToolLiveTests.PlanGuide.cs) drive the real tool methods against a live server/store and assert on actual outcomes (refusal reason, journaled actor, message text from the server), not on source spelling. - "
.gitattributessaystext=auto eol=crlf... a diff that touches every line is a line-ending conversion until proven otherwise" — does not apply — the two BOM removals (DarlingMcpInvestigationToolsTests.cs:1,PlanForceNoWritePathTests.cs:1) each touch exactly one line, confirmed viagit diff --stat, not a bulk line-ending rewrite.
Divergence (blind read vs PR body)
None — the body's "Round 3 (bot review) fixes" section matches the diff on every claim graded above: the journal-identity fix, the OBJECT-guide wording fix, the GateRefusalAnswerAsync wiring, the two BOM removals, and the CS8602 fix are all exactly what the tree shows, and both fixes map 1:1 onto the prior review's two confirmed "Riskiest lines" findings.
Reviewer checklist
- On sql2019, narrow the remediation login to
GRANT ALTER ON OBJECT::dbo.p, create an OBJECT guide, drop it by hand assa, then Undo, and confirm the answer isguide_not_found/ "already gone" with no database-ALTER sentence (the live assertion the new test makes, can't be executed from this read-only tree). - Run
AgentTokenIdentityGateLiveTests.EachRecompileModuleRefusalBeforeTheGate_IsJournaledUnderTheTokensNameagainst a live store and confirm all three cases journal under the token's name.
Claude posting for Erik Darling. Addressed in c9a4d34; the follow-up review 5641006373 is LGTM.
…#6569) RecompileModuleAsync, CreatePlanGuideAsync and DropPlanGuideAsync take the permit first like the other writes; the test now expects eight writes and nine public methods.
Claude posting for Erik Darling.
Fixes #6569.
Slice 1: recompile a module. Slice 2a (below): create a plan guide, with Undo. Slice 2b (
drop_plan_guideand its Undo, a re-create from the journaled definition) is also in, so all slices are on this branch.Why
An agent that finds a stale plan on a procedure, function or trigger had to run
sp_recompileoutside Darling, with no journal row. Slice 1 adds that as a Level 3 server action in the existing shape (the closest model isevict_cached_plan).What changes
recompile_module, toolrecompile_module(DarlingMcpPlanForceTools.RecompileModule.cs, a new file of the same partial class, so it shares the answer and refusal helpers andDarlingMcpPlanForceTools.cschanges by one word,partial). Inputs:database_name,module_name(schema.name),server_name,agent_action,dry_run(default true).agent_actions_enabled, a Level 3 request, the per-serverrecompile_moduleswitch, the remediation login, throughServerActionGatelike the others. A dry run is gated too.SqlServerRemediationExecutor.RecompileModule.cs): one batch in the request's database resolves the name withOBJECT_ID(@p0), reads the type andHAS_PERMS_BY_NAMEon the resolved schema and name, and (real run only) runssp_recompileon that same object. Refusals, nothing run:bad_module_name,bad_database,module_not_found,not_a_module(anything but P, PC, FN, IF, TF, FS, FT, TR),recompile_permission_denied,permit_mismatch, pluspostgres_target(before the gate, like evict). The write isEXEC sys.sp_recompile @objname = @resolved;inside that batch, with the input name as a typed parameter (@p0), never batch text. A lost reply isoutcome_unknownas for the other writes. The name is normalized to[schema].[name](exactly two parts, each at most 128 characters) and that form is the permit's id.action_called(before = database, module, object type),action_doneoraction_failed, kindmodule_recompile, an Agent changes row with no Undo (not inUndoableKinds), answerundo: null.ServerActionNames(All, Label, engine validity: SQL Server only),ServerActionForKind, the refusal sentence,set_server_action/get_server_actionstext, the Viewer (switch label, warning text, Agent changes kind word), the web switch page and Agent changes page,plain-text.js, the write policy andInvestigationRulesagent-tool lists, the web-endpoint no-route list, the brief composer's Level 3 sentence,AGENTS.example.md, README,docs/mcp-server.md, and the grants table indocs/darling-agent-dispatch.md(ALTERon the object).server-action-recompile-module) widensck_config_server_actions_actionto addrecompile_module, guarded so a second run replaces nothing; it inserts no row (starts off everywhere). Viewer probe sentinel (CHECK definition names the word),MapProbedSchemaVersionarm,GetBoolean(190),StorageVersion.SchemaVersion = 215, a rewind entry inRungRewindArtifacts, and a census entry for the rung inMigrationDataMovingRungCensusPins.Migration rung: V218. Dev's top is V217 (V215 agent-table indexes, V216 client timeouts on by default, V217 query store hints), and
TheLadder_IsDenseAboveTheHistoricalGapfails on a gap. If dev takes more rungs first, this PR renumbers (mechanical: theMigration(...)line,StorageVersion, the sentinel arm andGetBooleanindex, the rewind and census entries).sp_recompileexists on SQL Server and Azure SQL Database per Microsoft's documentation; I did not run against an Azure SQL Database (no target here), so the Azure claim rests on the documentation.Test plan
Darling.Tests-m:1: 0 errors (the 5 warnings are in files this PR does not touch).RecompileModuleTests): name normalization (plain, bracketed, dotted inside brackets,]], a quote), refusals for one-part, three-part, empty, unbalanced, control characters and over-128 names, statement text with a doubled quote, the refusal order and the allowed type set, permit mismatch (other action, other server, other object, no permit) with nothing run, the switch lists, journal kind and no undo.PlanForceToolLiveTests.Recompile.cs, throwaway database and procedure, dropped after): dry run showsUSE [db]; EXEC sys.sp_recompile @objname = N'[dbo].[p]';and runs nothing; a real run drops the procedure's cached plan (dm_exec_procedure_statsexecution count 3 before, 0 after; the observable marker), journalsaction_called/action_donewith the database and[dbo].[p]target, answersundo: nulland the journal id, no Undo on the Agent changes row; names with a quote or bracket resolve through the parameter tomodule_not_foundanddbo.tis intact; a table isnot_a_module; a missing name or another database ismodule_not_found;DENY ALTERgivesrecompile_permission_deniedand the plan stays; PostgreSQL target; switch off; no Level 3; actions off; no credential: each refused with nothing run. All pass.recompile_module, still refusesdrop_databaseby constraint name, a second run keeps the same constraint, and a store rewound one rung refuses the word until the rung re-runs.23514 ... ck_config_server_actions_action; restored.ServerActionGateTests,ServerActionSwitch*(store and write-live),PlanEvictTests,PlanForceToolLiveTests(all, on sql2019), the migration ladder and data-moving census, every*Census*and*Ratchet*,DarlingMcpInstructions*,DarlingMcpWritePolicy*,DarlingMcpInvestigationToolsTests,PlanForceNoWritePathTests,McpToolGuide*,ViewerSchemaVersionGateTests. Test expectations I had to move because they list tools or files: the served-writes list, the owner-source method list, the "executor named in one service file" list (now two files), and the tool counts.PlanForceToolLiveTests.AnUndo_OfAnotherKind_OrAnUnknownCall_OrABadLogin_IsRefusedWithoutTouchingTheServerexpectedbad_login, gotnot_found(the test's SQL login on this laptop is a 2-character name). The undo path is untouched by this PR.Lite.Tests,pre-push.sh(brief says CI decides); an Azure SQL Database.Considered, not added
recompile_modulehave one; force and evict do not).OBJECT_IDcannot see them, so they come back asmodule_not_found.What the coordinator should double-check
DarlingMcpPlanForceToolsbecamepartial; the tool method is the last member of the new file on purpose (the payload-contract census reads a tool's span to the next tool or end of file, and the body's own catch must not fall inside the one-line tool method).Slice 2a:
create_plan_guidewith Undo## Slice 2b.create_plan_guide(DarlingMcpPlanForceTools.PlanGuide.cs, executorSqlServerRemediationExecutor.PlanGuide.cs). Argumentsdatabase_name,name,statement,type(OBJECTorSQL;TEMPLATEis refused asbad_guide_type),module_or_batch,params,hints(must be anOPTION (...)clause). Every value is a typed parameter ofsys.sp_create_plan_guide; none joins batch text. Same gate asrecompile_module(service switch, Level 3, per-server switch, excluded database, engine; PostgreSQL refused aspostgres_target). Dry run is the default.not_a_moduleotherwise) andstatementmust appear inOBJECT_DEFINITIONbyte for byte (binary collation;statement_not_in_module). SQL: counts cached batches compiled in the guide's own database that equal the batch (or the statement) and reports it; zero is allowed and the answer says it matches nothing cached right now;nullwhen the login cannot read the cache. A name already in the database isguide_exists. All refused before anything runs.sys.fn_validate_plan_guideon the new guide; a row means the guide is dropped in that batch and the call isfailed/guide_invalidwith the server's message, journaledaction_failed.plan_guide_create;action_calledholds the whole definition (before.guide: name, type, statement, module_or_batch, params, hints;before.database);target.idis the guide name. The Agent changes row has Undo through the existing plan-undo path (UndoPlanChangeAsync, web route, Viewer command andundo_plan_changeall reach it): show the statement, confirm, drop withsys.sp_control_plan_guide N'DROP', journalaction_undoneas the person. It is gated by that server'screate_plan_guideswitch and login. A guide already gone is refusedguide_not_foundand changes nothing; a guide that no longer holds the journaled statement and hints (replaced under the same name) is refusedguide_changedand not dropped.SqlServerRemediationExecutor.DropPlanGuideAsync(permit, name, expected, dryRun)already exists (accepts adrop_plan_guidepermit) andPlanGuideDefinitionis the journaled shape; the create path takes aPlanGuideDefinition, so 2b can re-create from the journal.create_plan_guideanddrop_plan_guideare in V218's CHECK (no new rung). The rung's guard, the Viewer probe sentinel and the test rewind now key ondrop_plan_guide, the newest word, so a dev store that already ran the earlier V218 would still widen. Both are inServerActionNames.All, the labels, the Viewer and web switch lists. Thedrop_plan_guideswitch is read by the slice 2b tool.create_plan_guideis in the agent-action and write-policy lists and the web unserved list; the instructions census is 255 / 143.sp_create_plan_guidePermissions: an OBJECT guide needsALTERon the referenced object; a SQL or TEMPLATE guide needsALTERon the current database.sp_control_plan_guidePermissions: an OBJECT guide needsALTERon the referenced object; all othersALTER DATABASE. The create and drop batches now check only the grant that applies (the other is reported as satisfied), the refusal sentence names the one that is missing (does not hold ALTER on <module>for OBJECT,does not hold ALTER on the database <db>for SQL), and the grants row indocs/darling-agent-dispatch.mdsays the same, with the undo following the drop split. Live testALoginWithAlterOnTheProcedureOnly_CreatesAnObjectGuide_AndIsRefusedASqlGuideWithThePermissionSentence: the test login is taken out ofdb_ownerand grantedALTERon the procedure only; a SQL guide is refusedguide_permission_denied(dry run and write, nothing created), an OBJECT guide is created, and the person's undo drops it.sp_create_plan_guidepage says "Applies to: SQL Server, Azure SQL Database, Azure SQL Managed Instance, SQL database in Microsoft Fabric". Thesp_control_plan_guidepage says "Applies to: SQL Server" only. This tool needs both (it drops an invalid guide withsp_control_plan_guide, and the Undo drops with it), so the claim is narrowed to SQL Server: an Azure SQL Database (edition 5) or Managed Instance (edition 8) target is refused before the gate asengine_not_supported, next to the PostgreSQL refusal, with the reason in the tool description, and the docs say SQL Server only. Live testAGuideOnAnAzureSqlTarget_IsRefusedAsEngineNotSupported_ForADryRunAndAWrite(both editions, nothing created, nothing journaled). Links: https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-create-plan-guide-transact-sql and https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-control-plan-guide-transact-sqlPlanGuideTests(argument checks, refusal order, statement text, permit mismatch, switch lists, rung accepts both names and still refuses an unknown one: unit, plus a store fact that needsDARLING_TEST_PG),PlanForceToolLiveTests.PlanGuide.cs(live: dry run, OBJECT create plus journal plus Undo, Undo of a gone or replaced guide, SQL guide with zero matches, quote in the statement, validation failure, refusals, excluded database, PostgreSQL target, switch/level/actions off).Darling.Tests-m:1: 0 errors.PlanForceToolLiveTests(all parts),PlanGuideTests(the V218 store fact ran, not skipped),RecompileModuleTests,ServerActionGateTests,PlanForceNoWritePathTests,*Migration*,RungRewind*,ViewerSchemaVersion*,DarlingMcpWritePolicy*,DarlingMcpInstructions*,McpPayloadContract*,*RemediationExecutor*, every*Census*and*Ratchet*class: 977 run, 1 failed (a wrong expectation in my own live test, fixed, see below); after the fix the guide live tests plusPlanGuideTestsran 34, 0 failed. Not run: the full suite,Lite.Tests,pre-push.sh(CI decides).sys.fn_validate_plan_guidereturnsmsgnum,severity,state,message: the batch readsmessage, which is right. A valid guide returns no row.sys.plan_guides.query_textandhintsread back byte-identical to what was sent, including tabs, a line break, runs of spaces andOPTION (RECOMPILE, MAXDOP 1); so theguide_changedcheck needed no change.USE PLANis NOT refused bysp_create_plan_guide: the guide is created, andsys.fn_validate_plan_guidethen returns message 8698 ("USE PLAN hint contains plan that could not be verified to be legal for query"). So the batch's validate-then-drop path is the one that fires.AGuideThatFailsValidation_IsNotLeftBehind_AndIsJournaledFailednow assertsguide_invalid, the server's message, no guide left, oneaction_failedand noaction_done(before it accepted anyfailed).No cached batch in this database matches this guide); the cached-count test ran the statement with a trailing;while the count is of byte-equal batch text (now run without it); the quote test expectedit''swhere the statement's own doubled quote doubles again (it''''s).if (false && !statementInModule)),AStatementNotInTheModule_AndATakenName_AndATemplate_AreRefused_AndNothingRunsfails (Expected "refused", Actual "dry_run"); restored, it passes.RunAgentActionAsyncdoes for the other kinds (no concrete trigger beyond the existing one); a size cap per field tighter than 4,000,000 characters (aUSE PLANhint carries a whole plan).Slice 2b:
drop_plan_guidewith Undodrop_plan_guide(argumentsdatabase_nameandname), inDarlingMcpPlanForceTools.PlanGuide.cs, same gate as 2a (service switch, Level 3, per-serverdrop_plan_guideswitch, excluded database, engine; PostgreSQLpostgres_target, Azure SQL Database and Managed Instanceengine_not_supported). Dry run by default.DropPlanGuideAsync(2a) now serves both: with adrop_plan_guidepermit it is the drop action. In ONE batch on the request's database it reads the guide fromsys.plan_guides(query text, scope type, the module's two-part name for OBJECT, scope batch, parameters, hints,is_disabled), refusesguide_not_foundand a TEMPLATE guide (new reasonguide_template, because Undo cannot re-create one), checks the grant for the type, then runssys.sp_control_plan_guide N'DROP', @name. Every value is a typed parameter. The dry run shows the statement and the definition that would be journaled; the write passes that definition back asexpected, so a guide replaced between the dry run and the write is refusedguide_changed.plan_guide_drop;action_calledholdsbefore.databaseandbefore.guide(name, type, statement, module_or_batch, params, hints, is_disabled),target.idis the guide name, thenaction_done/action_failed; an Agent changes row with Undo (the plan-undo path the web route, the Viewer andundo_plan_changeall reach;IsPlanChangenow includes the kind).DecideAndRunGuideRecreateUndoAsync): show the create statement (plus theDISABLEstatement when it was disabled), confirm, then re-create through 2a'sCreatePlanGuideAsync(same match checks, validate-then-drop-on-failure), disabling it after validation in the same batch when it was disabled. A name that exists now isguide_exists: nothing changes. It is gated by that server'sdrop_plan_guideswitch and remediation login (the switch of the action being undone, as 2a's undo usescreate_plan_guide);CreatePlanGuideAsyncaccepts a permit of either action for that.ALTERon the procedure only does not see a SQL guide insys.plan_guidesat all, so the drop read it as not found. The batch now reads the database grant first; a guide that is not found for a login withoutALTERon the database is refusedguide_permission_deniedwith the permission sentence ("cannot see it: does not hold ALTER on the database"), notguide_not_found. A login with the grant still getsguide_not_found. The same applies to 2a's undo of a create for such a login (it had no test).docs/mcp-server.md(six acting tools, agent-action list);AGENTS.example.md; theset_server_action/get_server_actionstext and the brief composer's Level 3 sentence now namecreate_plan_guideanddrop_plan_guide(2a had left them out). Lists: write policy,InvestigationRules, web no-route list,plain-text.js, Agent changes kind words (web and Viewer). Instructions census 256 tools / 144 unique to Darling.McpToolGuideTests.EveryConvertedHead_StaysAtOrUnder620Charactersfailed oncreate_plan_guide(629 characters); the head is shortened.Darling.Tests-m:1: 0 errors; the one warning is inChangeWatchDetectionLiveTests.cs, which this PR does not touch.PlanForceToolLiveTests.PlanGuideDrop.cs, throwaway database each, own PostgreSQL on port 55692 stopped after): dry run (nothing dropped, nothing journaled, default true); an OBJECT guide and a disabled SQL guide with parameters dropped, gone, journal holds the definition, Undo shows then re-creates and thesys.plan_guidesrow (text, scope, scope batch, parameters, hints,is_disabled, scope object id) equals the original; Undo while a same-name guide exists isguide_existsand the replacement is untouched; guide not found and TEMPLATE refused (dry run and write), TEMPLATE survives; withALTERon the procedure only the OBJECT guide drops (and its Undo re-creates it) and the SQL guide is refused with the permission sentence; switch off, no Level 3, actions off, excluded database, PostgreSQL target, Azure editions 5 and 8: each refused, guide still there, nothing journaled. Plus unit tests inPlanGuideTests(DISABLE statement text, name check, lists).AGuideThatIsNotThere_AndATemplateGuide_AreRefused_AndNothingIsDroppedfails (Expected "refused", Actual "dry_run"); restored, it passes.PlanForceToolLiveTests(all),PlanGuideTests,RecompileModuleTests,*ServerAction*,PlanForceNoWritePathTests,*AgentChangeUndo*,*Migration*,*RungRewind*,*WritePolicy*,*NeverAgent*,*Instructions*,*PayloadContract*, every*Census*and*Ratchet*,*InvestigationBrief*,*InvestigationRules*,*ViewerSchema*,*McpToolGuide*,*InvestigationTools*,*WebEndpoints*: 1600 run, 1 failed (the 620-character head above, fixed;McpToolGuideTests,PlanGuideTestsandDarlingMcpInstructions*re-run after: 45 run, 0 failed).Lite.Tests,pre-push.sh(brief says CI decides).paramsandis_disabledbetween the dry run and the write (theguide_changedcheck compares statement and hints only; no concrete trigger). A guard for an OBJECT guide whose module no longer resolves (the module name reads null, the grant check refuses it, so nothing is dropped).CHANGELOG entry
SECTION: Added
ENTRY:
recompile_modulerunssp_recompileon one module in the request's database through the remediation login, behind its own per-server switch (off by default; migration V218 widens the switch table), dry run by default, journaled with no undo. It also addscreate_plan_guide, a Level 3 server action that creates one OBJECT, SQL or TEMPLATE plan guide (with OPTION hints, an XML showplan or no hints, whateversp_create_plan_guidetakes) withsp_create_plan_guideafter matching it and validating it (an invalid guide is dropped again), journaling the whole definition, with an Undo from Agent changes that drops the guide by name; its switch and thedrop_plan_guideswitch are in migration V218. It also addsdrop_plan_guide, a Level 3 server action that reads one guide's whole definition fromsys.plan_guidesand drops it withsp_control_plan_guide(a missing guide is refused; every guide form is dropped, TEMPLATE included), journaling the definition, with an Undo from Agent changes that re-creates the guide in the same form (validated, and disabled again if it was disabled) and is refused if a guide with that name exists by then. A database the server's entry excludes is refused, for a dry run too. An Undo of a plan guide create or drop acts only on a row an agent's create or drop tool wrote, applies the create's checks to the journaled definition, and refuses an excluded database or an Azure SQL or PostgreSQL target. A SQL guide's cached-batch count reads only its own database, and a permission refusal for a guide on a DML trigger names the trigger's table. Self-managed stores get the updated journal rule at the service's next start, or by re-runningprovision-roles.sql.REF:
[Darling server actions: recompile a module, and create or drop a plan guide #6569]: Agents can recompile a module and create or drop a plan guide through Level 3 server actions, with Undo (#6569) #6635
Round 1 review
ADatabaseTheServersEntryExcludes_IsRefused_ForADryRunAndAWrite_AndNothingIsReadOrRun).recompile_modulenow checks the server entry's excluded list with the same helper and the same sentence as the Azure kill ("excluded in this server's entry, so Darling does not act in it"), reasondatabase_excluded, after the gate and before the dry run reads anything. The test checks the dry run and the real run: both refused, the procedure keeps its three cached runs, no journal call row. Reverting the check makes the test fail.AValidTwoPartNameLongerThan256Characters_Resolves_AndRuns_AndTheJournalNamesTheResolvedObject, plusANameOverTheLimit_IsRefusedBeforeTheServerIsReachedand a unit assertion that the longest normal form is 517 characters).@p0is sizedModuleRecompileStatement.MaxNormalLength(517), not 256. The answer'sbeforeand the journal'saction_called.beforenow carryschema,nameandmoduleas the server resolved them (OBJECT_SCHEMA_NAMEando.nameof the resolved id), and the done message names the resolved object. The test uses a 128-character schema and a 128-character procedure (261 characters normalized) on sql2019, past the old 256. Reverting the size to 256 makes it fail. Note: sql2019's server collation is case sensitive, so the test cannot show a name that differs from the input only in case; the journal assertions there check the resolved parts for the long name.sys.sp_recompileare one statement batch: it resolves the id, checks the type is one of the accepted codes and that the login holdsALTER, and runssp_recompileon that object's schema and name only when@runis 1 and both hold. It returns found, type, grant, resolved schema and name, and whether it ran. The dry run uses the same batch with@run0 and still shows the exact statement. All existing live recompile tests pass; the table-name test still refusesnot_a_modulewith nothing run. A lost reply on a real run is stilloutcome_unknown.docs/darling-agent-dispatch.mdand therecompile_permission_deniedsentence now say: a procedure or function needsALTERon the module; a DML trigger needsALTERon its table, which also allows changing and emptying that table, so grant it only where that is acceptable.AnUndo_OfAnotherKind_OrAnUnknownCall_OrABadLogin_IsRefusedWithoutTouchingTheServer). The bad-login case uses a journal row the test seeds. Also found while fixing it:"bad login!"is a valid person name (inner spaces and!are allowed since the name rules changed), so the old case did not test the login refusal at all; it now uses a login with a control character. The class passes with the test run alone.Migration: dev's top rung is V217 (V215 agent-table indexes, V216 client timeouts on by default, V217 query store hints), so this PR's rung is V218 (renumbered from V215 in the dev merge below).
Tests run (rig on its own throwaway PostgreSQL, sql2019 only, throwaway databases dropped; none left):
PlanForceToolLiveTests(all parts),ServerActionGate*,PlanEvict*,*Recompile*,*Migration*,DarlingMcpWritePolicy*,DarlingMcpInstructions*,McpPayloadContract*,*RemediationExecutor*, every*Census*and*Ratchet*class: 931 run, 0 failed. After the last merge of dev (docs and write-policy text only) the build is 0 errors and the recompile, write-policy, payload and instructions classes pass except one class that needs the PostgreSQL rig, which I had already stopped (it passed in the full run before). Not run: the full suite andpre-push.sh(CI decides). The build shows 8 warnings, all in files this PR does not touch (dev's own).Considered, not added: refusing an over-long input separately (the parser already refuses a part over 128 characters before the server is reached, test added); a retry on a lost reply (the existing
outcome_unknownanswer already says running it again does no harm).Review round 2
Claude posting for Erik Darling.
Commit beafc0b. Four items, each Fixed.
DarlingMcpPlanForceTools.PlanGuide.cs,AgentChangeUndo.PlanUndo.cs).PlanGuideDefinition.TryCreate, the call the create tool uses, so hints that do not start withOPTION (, an OBJECT module that does not normalize to[schema].[name], a TEMPLATE type and over-length text are refused asnot_undoable. Both Undos run the name throughPlanGuideDefinition.TryNameand refuse a name over 128 characters instead of lettingNVarChar(128)cut it.database_excluded), an Azure SQL Database or Managed Instance (edition 5 or 8,engine_not_supported) and a PostgreSQL target (postgres_target), each recorded withsession.RecordRefusalAsync.ReadPlanFactsAsync, for the two guide kinds, refusesnot_undoableunless the journal row's actor isagent:...(the create and drop tools always record"agent:" + action.AgentIdinRunAgentActionAsync, for every token), the request row exists with the sameserver_idas the journal row, and the request's stamped level is 3 or more. I checked the caller paths: the actor shape does not depend on the token, so there is no person actor to keep undoable for these two kinds.PlanForceToolLiveTests.PlanGuideUndoChecks.cs, live, sql2019 and own PostgreSQL):TheUndoOfADropWhoseJournaledDefinitionTheCreateRefuses_IsRefused_AndNothingRuns(bad hints, module that does not normalize, TEMPLATE, name over 128; the real row still undoes),TheUndoOfACreateWhoseJournaledNameIsOver128Characters_IsRefused_AndNothingRuns,TheUndoOfAGuideCreateOrDrop_OnAnExcludedDatabase_AnAzureTarget_OrAPostgreSqlTarget_IsRefused_AndRecorded(four cases, both kinds, refusal row checked),AGuideRowAnAgentActionDidNotWrite_IsNotUndone_ButARealCreateAndDropStillAre(person actor, request on another server, request with no row, request not Level 3; real rows still undo). Revert-proved (c): with the check disabled the last test fails; restored.<<GUIDE>>marker, thecreate_plan_guidetext now says table hints that change isolation or locking (NOLOCK, READUNCOMMITTED, READPAST, TABLOCKX, XLOCK and the like) change which rows the query returns or how it blocks other sessions, and an agent uses them only when the person asked or after comparing the query's results and run time with and without the hint, stating what it compared. The head before the marker is unchanged. No hint list, no code change, no test.AND st.dbid = DB_ID(). The count text (MatchWords, the tool description,RemediationResult.CachedMatches, the dispatch doc) now says "in this database". TestASqlGuideCountsOnlyTheBatchesCachedInItsOwnDatabase: a batch cached only in a second database counts 0, then counts once it is cached in the guide's database.PlanGuideTestsand the one live message assertion were updated for the new sentence.docs/darling-agent-dispatch.md: the force_plan, create and drop rows say a procedure or function guide needsALTERon it (which also lets the login change its code), a DML trigger guide needsALTERon the trigger's table (ALTERcannot be granted on a trigger; also lets the login alter or truncate the table), and a SQL guide needsALTERon the database (also lets the login create users, change roles and schemas, and add database DDL triggers: grant it only where that is acceptable). Both batches now read the trigger's parent table (OBJECT_SCHEMA_NAME(parent_object_id),OBJECT_NAME(parent_object_id)), and the create and drop permission refusals name that table. Tests: unitTheRefusalForAnObjectGuideOnATrigger_NamesTheTriggersTable_AndOneOnAProcedureNamesTheProcedure; liveALoginWithoutAlterOnTheTriggersTable_IsRefusedAnObjectGuideOnTheTrigger_WithTheTableNamed(create and drop refused with the table named; afterGRANT ALTERon the table both go through, so the grant the docs name is the one that works).Ran (own PostgreSQL on port 55639, stopped after; sql2019 only, scratch databases and a temporary sysadmin login dropped after):
PlanForceToolLiveTests(all parts),PlanGuideTests,RecompileModuleTests,ServerActionGateTests,PlanForceNoWritePathTests,*AgentChangeUndo*,*AgentChangeToolUndo*,*DarlingMcpWritePolicy*,DarlingMcpInstructionsBudget*,*McpToolGuide*,McpPayloadContract*, every*Census*and*Ratchet*,*PlanForceAction*,*AgentActionGate*: 1020 run, 1 failed (PlanGuideTests.TheMatchWords_SayWhatAGuideAppliesTo, a message assertion I had not updated; fixed in beafc0b and rerun green). The first census run caughtDROP DATABASEtext in my new live file (UnpooledDropConnectionCensusTests); the test now uses a secondSqlScratchinstead, and the census passes. Not run: the full suite andpre-push.sh(CI decides).Considered, not added: a check that the live routes still send the request at Level 3 when the Undo runs. The Undo uses the level the request was stamped with when the tool ran; no concrete trigger for a route change making an earlier change wrong to undo.
Guide forms
Claude posting for Erik Darling.
The plan guide tools now take every guide form
sp_create_plan_guidetakes, so a dropped guide can always be undone. This replaces the earlier OPTION-only create checks and the TEMPLATE refusals in slices 2a and 2b and in review round 2 (the test names and revert-proof lines above that mention them are superseded by this section).What changes
PlanGuideDefinition.TryCreate(SqlServerRemediationExecutor.PlanGuide.cs):Hintsis now nullable. Hints are anOPTION (...)clause as before, an XML showplan (text that starts with<, after an optional<?xml ...?>prologue), or none (empty or null becomes NULL: the guide removes the statement's own OPTION hints). NULL goes through the create parameter (DBNull.Value),PlanGuideStatement.CreateprintsNULL, and the journal image keeps it as JSON null. Type TEMPLATE is accepted:module_or_batchmust be empty,paramsis required, hints are checked by SQL Server. Kept: the length limits,TryName, a bad type, an empty statement, OBJECT with params, a module that does not normalize. A TEMPLATE guide runs under the SQL guide permission (ALTER on the database, Microsoft's rule), the create batch skips the cached-batch count for it, and the result carries no count (MatchWordssays no count applies).DropPlanGuideBatchSqlandDropPlanGuideAsync):@refuse_templateis gone andguide_templateis removed, so the drop takes a TEMPLATE guide; the expected-definition compare is NULL-safe on both sides (hints that changed to or from NULL count as changed); the journaled image reads the type from scope (1 OBJECT, 2 SQL, 3 TEMPLATE), takes no batch for a TEMPLATE guide, and keeps NULL hints as NULL (it was turned into an empty string).TryCreate, and the create Undo's expected definition keeps NULL hints, so both still match the guide by statement and hints.<<GUIDE>>marker, heads unchanged):create_plan_guidenames the three hint forms with one line of guidance each and the TEMPLATE type (empty module, params required, PARAMETERIZATION hints, statement and params fromsys.sp_get_query_template);hintsis now optional.drop_plan_guidesays every form is dropped and re-created by the Undo.docs/darling-agent-dispatch.mdrows updated to match.Tests
PlanGuideTests): XML showplan hints (with and without prologue) for OBJECT and SQL, NULL hints for each type, TEMPLATE shape and its refusals (module present, params missing or blank), a bare<or a prologue with no body refused,NULLprinted in the statement, no cached count for TEMPLATE. The old TEMPLATE-is-bad-type rows now expect the new argument refusals; no test deleted.PlanForceToolLiveTests.PlanGuideForms.cs):AGuideWithXmlShowplanHints_NullHints_OrOfTypeTemplate_IsDropped_AndItsUndoRebuildsTheSameDefinitionmakes the three guides by hand, drops each withdrop_plan_guide, Undoes each, and checks the wholesys.plan_guidesrow (text, scope, batch, parameters, hints, disabled, scope object) is the same again.ACreateWithXmlShowplanHints_NullHints_OrOfTypeTemplate_IsUndoneByDroppingTheGuidecreates the three forms withcreate_plan_guideand Undoes each.AGuideThatIsNotThere_IsRefused_AndATemplateGuide_IsDropped_AndUndonereplaces the old TEMPLATE refusal test.PlanForceToolLiveTests(all parts),PlanGuideTests,RecompileModuleTests,ServerActionGateTests,PlanForceNoWritePathTests,*AgentChangeUndo*,*AgentChangeToolUndo*,*DarlingMcpWritePolicy*,DarlingMcpInstructionsBudget*,*McpToolGuide*,McpPayloadContract*, every*Census*and*Ratchet*,*PlanForceAction*,*AgentActionGate*: 1034 run. The one failure wasMcpToolGuideTests.EveryConvertedHead_StaysAtOrUnder620Characters(my first edit lengthened thedrop_plan_guidehead to 624); I put the head back and moved the sentence after the marker; that class and the budget, payload and write-policy classes then passed (345 run). Own PostgreSQL stopped after; sql2019 scratch databases and logins were dropped by the tests (checked: none left). Not run: the full suite andpre-push.sh(CI decides).Considered, not added
<": SQL Server parses it, and an invalid one fails the create or the post-create validation, which drops it. No trigger that the cheap check lets through something harmful.hintsfrom an agent that forgot the argument creates a guide with NULL hints (it removes the statement's own OPTION hints). The tool text says so and the dry run showshints: null; a dry run is the default.CHANGELOG draft (the
## CHANGELOG entryabove is extended):create_plan_guideanddrop_plan_guidetake every guide form SQL Server takes (OPTION hints, XML showplan hints, no hints, TEMPLATE guides), and the drop Undo re-creates any of them.Double-check: the
(object?)guide.Hints ?? DBNull.Valuecreate parameter and the NULL-safe compare inDropPlanGuideBatchSql; the drop's journal image for a TEMPLATE guide (no batch).Agent action rows
Claude posting for Erik Darling.
The service now writes the agent action rows in the investigation journal itself, and the store keeps those rows to the service. Before this, a plan guide or plan force Undo acted on the
action_calledrow, but that row was written on the MCP tools' own login (mcp), which holds INSERT on the journal. So a row that no agent action wrote could sit in Agent changes and be offered for Undo. Now:action_called,action_doneandaction_failedare owner-only journal events, likeserver_action_switched(Agent dispatch: turn a server's agent action switches on and off from the viewer, the web and MCP #6269): the journal trigger refuses them frommcpandadmin. The list isAgentActionEvents.OwnerOnly;JournalOwnerEventListappends it, andDarling/tools/provision-roles.sqlhas the same text (the trigger function and its step 3i header comment).InvestigationRequestStoretakes an optionalagentActionSource(the store owner's data source).RunAgentActionAsyncruns every transaction on it: the request checks (token, claim, request, Level, server: unchanged), theaction_calledinsert, the write hook's own transaction with itsaction_doneoraction_failed, and the lateaction_done. The store changes the Level 2 tools make (mute rules, custom alert rules, thresholds, signature merges) are in the same transaction, so they now run on the owner login too; the service's code decides what is written and the agent's arguments are validated as before. A store with no owner source (tests, the Viewer) runs on its own source.DarlingAgentActionSeat.Owner(the host builds the seat fromStoreOwnerSource);set_alert_threshold_overridetakesStoreOwnerSourceas a service parameter.refusedandjournal_cappedstay writable bymcpandadminon purpose: the claim and submit tools journal them asmcp.RecordAgentActionRefusalAsyncandCheckServerActionAsynctherefore stay on the store's own source.postgresis the owner there); the Viewer never writes these events; Undo writesaction_undoneandagent_actions_switched(not moved). One path was not on the owner: theundo_plan_changeMCP tool built itsAgentChangeUndoonmcp, and an Undo whose reply was lost journals anaction_calledmarker (kindplan_undo, best effort). It now builds it on the owner source it already requires (owner ?? postgres), so that marker is still written.Darling/tools/provision-roles.sqlrun again to get the new trigger list. A managed store takes it at the next start.CHANGELOG draft (extends the entry above)
Agent changes only list what the service itself journaled: the
action_called,action_doneandaction_failedrows can no longer be written by themcporadminlogins, only by the service. A store provisioned by hand needsprovision-roles.sqlrun again. Rows written earlier are unchanged.Test plan (this part)
Darling.Testssucceeds (also after merging the branch's latest commit); the 7 warnings are in files this change does not touch.-classruns of*AgentLevel2Store*,*InvestigationRequestEventsLiveTests*,*DarlingManagedRolesTests*,*ProvisionRoles*,*InvestigationRequestStoreLiveTests*: 142 passed, 0 failed. They cover: the managed and the script copy of the trigger refuse each of the three events from the writer role; the realmcpandadminlogins are refused on a managed and a script-provisioned store; the gate onmcp's own source stops ataction_calledwith 42501 and writes nothing, and on the owner source journalsaction_called|action_done.*AgentChangeUndo*,*AgentActionGate*,*AgentLevel2*,*SignatureMerge*,*AgentChangeToolUndo*,*ThresholdOverride*,*Census*,*Ratchet*: 754 run, 2 failed:AgentLevel2ToolPolicyTests.TheInvestigationsArm_ServesExactlyTheThirteenActionTools_OfTheWritesandTheInstructionsCensus_UnderTheInvestigationsArm_CountsTheThirteenAsServed_.... The served roster now holdscreate_plan_guideat index 2 (the plan guide slices); this change adds and removes no tool. I did not run them on the branch tip before my change, so whoever owns the plan guide slices should confirm.InvestigationRules.AgentActionToolNames(the list force uses), includingrecompile_module,create_plan_guideanddrop_plan_guide. Fixed in c7cb50a: both tests now list the sixteen and are renamedTheInvestigationsArm_ServesExactlyTheActionTools_OfTheWritesandTheInstructionsCensus_UnderTheInvestigationsArm_CountsTheActionToolsAsServed_AndStillSaysTheWritesAreOff; the instructions text now names recompile and plan guide beside plan force, plan evict and session kill (the intro sentence and the Level 3 clause); the count in the text is taken from the list, not typed.PlanForceToolLiveTests(all parts, includingJournalOwnerandPlanGuideForms), on sql2019 (loginsa, no login created) and an own PostgreSQL rig: 123 run, 0 failed, 0 skipped (460 s). Nopm_pf_*database or login left on sql2019, and no plan guide.JournalOwnerEventListconcat and theprovision-roles.sqllist;AgentActionEvents.OwnerOnlyleft alone),*DarlingManagedRolesTests*,*ProvisionRoles*,*InvestigationRequestEventsLiveTests*,*AgentLevel2Store*,*InvestigationRequestStoreLiveTests*: 142 run, 4 failed (TheScriptsJournalTrigger_RefusesEveryEventKindAndMark_FromTheMcpRole,TheJournalTrigger_Refuses...FromTheMcpRole_AndTheServiceStillWrites,AgentLevel2StoreGrantLiveTests.TheBringYourOwnScript_...,...TheManagedBatch_...). Restored; same classes pass.*AgentLevel2*, the events, roles, provision and store live classes,*AgentChangeUndo*,*AgentChangeToolUndo*,*PlanGuideTests*,*RecompileModuleTests*,*DarlingMcpWritePolicy*,*DarlingMcpInstructions*,*McpToolGuide*,*McpPayloadContract*,*McpToolsListBudget*,*AgentActionGate*,*ServerActionGate*,*PlanForceNoWritePath*, every*Census*and*Ratchet*: 1109 run, 2 failed on the first pass, both fixed in c7cb50a and rerun green (795 run, 0 failed):McpToolsListBudgetTests.ConvertedTools_HonorD2sAbsoluteCaps_AndNoMarkerReachesTheWire(thecreate_plan_guidehintsparameter description was 264 against the 230 cap; shortened to 178, the three hint forms stay in the tool text after the marker) andPlanForceNoWritePathTests.OnlyTheForceToolClass_TakesTheStoreOwnerSource(its list now includesDarlingMcpThresholdOverrideTools.SetAlertThresholdOverride, which takes the owner source since the agent action rows change; the rule, that only action tools take it, holds).pre-push.sh(CI decides, per the brief).Considered, not added
mcp, the firstaction_calledinsert is refused by the trigger, and nothing is changed. The window is the first seconds after the service starts, before any agent can hold a claim.Final review round
Claude posting for Erik Darling.
Four changes in behavior:
action_called,action_doneandaction_failedfrom the mcp and admin logins at the next start. A store below V191 (no journal, or nopractice_of) is left alone, as the script leaves it. A login that cannot replace the function gets the one existing warning, which now reads "password or journal rules" and namesprovision-roles.sql. The three DarlingWorker call sites are unchanged (SelfManagedStoreRulesStartPinTestspasses).drop_plan_guiderefuses a guide its Undo could not re-create. After the guide is read, its definition goes through the same checks a create makes. When it fails (hints or statement over 4,000,000 characters, or an OBJECT guide on a module name that does not normalize), the answer isrefused, reasonguide_not_undoable, with the check's own message, on a dry run and on a write, and nothing is dropped. The refusal is journaled like the tool's other refusals. The read of the guide still goes through the gate first (it needs the permit), so the refusal comes before the write and before any journal row of the action.ownerSource.Read()when that is published, as the MCPundo_plan_changedoes, so a result-unknown row is no longer refused when the web connection is the admin login.IsSignedInAdminandCanDecidestay on the web login.UndoPlanChangeAsyncinDarlingWebEndpointsbecameinternalso a test can drive it.undo_plan_changenames all four kinds (plan_force,plan_unforce,plan_guide_create,plan_guide_drop) and the switch for each (force_plan,create_plan_guide,drop_plan_guide). Its answer's "how to put it back" hint no longer says only force or unforce, and theget_agent_changesnote lists the four kinds.Two edits the merge with dev forced:
AgentLevel2MuteRuleToolTestspinned the old parenthetical wording, so its string follows.ProvisioningSerializationTestsholds the password rules' command to the locked transaction. Its rule still holds; its regex now matches the new lambda (journal SQL added to the same single command).Test plan
StoreServerPasswordRulesLiveTests(new:AStoreHoldingTheOlderJournalFunction_GetsTheOwnerOnlyEventListFromTheServicesStart_...: older list installed, an mcp-grant insert ofaction_calledaccepted before, refused 42501 after; a second start leaves the function row version unchanged; a login that is not the owner gets one warning naming the script andfalse; rules in place from another role warn nothing)PlanForceToolLiveTestson sql2019 (new:AGuideTheUndoCouldNotReCreate_...with a 4,000,000-character hint the server took;AWebPlanUndo_OnAStoreWhoseWebLoginIsHeldByTheJournalTrigger_..., a web Undo whose reply is lost writes its plan_undo / unknown row), whole class runSelfManagedStoreRulesStartPinTests,InvestigationRequestEventsLiveTests,PlanGuideTests,AgentChangesWebLiveTests,DarlingCommandExecutorTests,McpIncidentActionsLiveTests,PlanForceNoWritePathTests,McpToolsListBudgetTests,DarlingMcpInstructionsBudgetTests,McpPayloadContractCensusTests,DarlingManagedRolesTests,ProvisionRoles*,ProvisioningSerializationTests,AgentChangeUndoLiveTests,McpToolGuideTests, every*Census*class: first batch 882 tests, 2 failures (the two edits above); after the fixes the re-runs (108, 80 and 545 tests) have 0 failurespre-push.shnot run (laptop at its thermal limit; CI decides). The last merge oforigin/dev(after the tests above) was not re-tested.DarlingMcpAlertTools.cs, which this PR does not touch (from dev).Considered, not added
investigation_request_alerts_owner_rows) is not re-applied at start: its text did not change in this PR, so no store holds a stale copy from it.Round 3 (bot review) fixes
Claude posting for Erik Darling.
recompile_module's three refusals before the gate (bad_module_name,bad_database,postgres_target) now passagentTokenNametoRefuseBeforeGateAsync, as the kill, evict and plan guide tools do, so a named agent token's refusal is journaled under the token's name. Test:AgentTokenIdentityGateLiveTests.EachRecompileModuleRefusalBeforeTheGate_IsJournaledUnderTheTokensName(three cases). A named token callsrecompile_modulewith a differentagent_id, and the refusal row's actor is the token's name. Revert proof: with the three call sites put back, all three cases fail.sa, an OBJECT guide ondbo.p; a user holding onlyGRANT ALTER ON OBJECT::dbo.p(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'ALTER')= 0, on the object = 1) does see the guide insys.plan_guides(scope_type_desc OBJECT). So the!found && !grantedDbbranch inSqlServerRemediationExecutor.PlanGuide.csnow also requiresexpected?.Typenot to be OBJECT: an OBJECT guide that is gone answersguide_not_found("already gone"). The drop action has no journaled type and keeps the permission answer. Test:PlanForceToolLiveTests.ALoginWithAlterOnTheProcedureOnly_UndoingAnObjectGuideThatIsAlreadyGone_SaysItIsGone_NotThatTheGuideIsHidden(the narrowed login creates the OBJECT guide,sadrops it by hand, then the Undo answersguide_not_foundwith "already gone" and no database ALTER sentence). Revert proof: with the old condition it fails. The probe database and login were dropped.recompile_module,create_plan_guideanddrop_plan_guidenow callGateRefusalAnswerAsync(...)like the three older server action tools, so a dry run refused for want of the claim also says whether that server's switch for the action is on. No existing test pinned the old text; the targeted classes pass unchanged.DarlingMcpInvestigationToolsTests.csandPlanForceNoWritePathTests.cs; the diff againstorigin/devon both shows no first-line change.CreatePlanGuideAsync(permit!.Database, as the drop path already has), which showed up in the merged build.Run: sql2019 and a throwaway PostgreSQL rig, nothing skipped.
AgentTokenIdentityGateLiveTestsplus*PlanForceToolLive*first (145 passed), then the brief's whole class list plus*McpWriteToolWording*after the last dev merge: Total 1283, Failed 0, Skipped 0. The four new cases failed (4 of 4) with the fixes reverted.Considered, not added
Dev merge
Merged dev cb20aea (3.10.0 plus V215-V217) into this branch (head 9a5fd0e); the migration is now V218 (
server-action-recompile-module). Darling build 0 warnings, 0 errors. Targeted classes (*Migration*,*SchemaVersion*,*PgSchemaGenerator*,*PlanGuide*,*RecompileModule*,*ServerAction*,*StoreServerPasswordRules*,*SelfManagedStoreRules*,*InvestigationRequestEvents*,*McpToolsListBudget*,*Instructions*,*McpPayloadContract*,*DarlingManagedRoles*,*ProvisionRoles*,*DarlingCustomViews*,*Census*,*Ratchet*): Total 1185, Failed 0, Skipped 0, on a throwaway PostgreSQL rig (stopped after). The live SQL Server tests were not rerun in this merge (no sa password available to this lane); the merge changes no behavior.CatalogCensusreads 264 tools / 152 Darling-only (dev's 261/149 plus this PR's 3).Round 3: merged dev fca8641 again (head c9a4d34). Census 273 / 112 / 161 (dev 270 / 112 / 158 plus this PR's 3 tools);
plain-text.jsandPlanForceNoWritePathTests.cstake the union of both sides. Darling build 0 warnings, 0 errors; 1283 tests, 0 failed, 0 skipped.Seat fix after CI (36e70cd)
CI leg 8 failed
RemediationOfflineTests.EveryWrite_TakesTheGatesPermitFirst_AndAPermitCannotBeBuiltOutsideTheStorageAssembly: it expected 5 executor writes and found 8. The three new writes (RecompileModuleAsync,CreatePlanGuideAsync,DropPlanGuideAsync) take the permit first like the others, so the test now expects 8 writes and 9 public methods. Test change only.