Skip to content

Commit f28cc95

Browse files
fix: enforceable Agent writes plus non-blocking save bubbles (#75)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Problem Agent chat answered a metric, then offered **“Should I save this as a shared DeepSQL brain note?”** A user without `canManageContent` clicked Yes and got a 403. The offer itself is the bug. Admins still need a way to persist a useful definition — without the Agent asking, without blocking chat, and without creating a second note that overlaps an existing brain note or business rule. Chips must stay **quiet** when the Agent already got it right. ## Fix **Capability gating** - Stamp `callerCapabilities` (including `doNotOffer` and `canWriteSharedBrainNotes`) on `get_brain_context`, `list_connections`, `list_brain_recommendations`, and `get_current_user`. - `save_brain_note` fail-closes **before** `POST /brain/notes` when `canManageContent` is false. - `SOUL.md` / `bi-query` skill: never volunteer a write listed in `doNotOffer`. **Suggestion bubbles — only after correction or teaching** - A clean first-turn answer never gets a chip, even if it looks like a definition. - “Thanks, that’s right” after a good answer also stays quiet. - A chip appears only when the user follows up with a correction/teaching phrase (`that's wrong`, `always filter`, `remember this`, …) after a prior Agent reply. - Clicking the chip opens an excerpt of the shared brain note that would be created, plus **Save to brain** / **Merge and save**. - Propose/accept never blocks the composer. - One unsaved chip at a time; dismissed/saved targets stay suppressed for the session. - Overlaps with existing notes or business rules merge into one intent. - `schema.table` extraction is a linear scan (no ReDoS regex). ## Verify - `cd backend && ./mvnw test -Dtest=BrainNoteIntentServiceTest,BrainControllerAuthorizationSafetyTest` - `node --test src/components/AgentChat/shouldOfferBrainSuggestion.test.js src/components/AgentChat/AgentRecommendationBubbles.test.js` - Live `POST /brain/notes/propose`: first-turn and thanks → `204`; correction with `priorAnswer` → `200` `NEW` / `MERGE` [Suggestion chip opens an excerpt of the brain note](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fbubble_excerpt_card.webp) [After save the chip is gone and the composer stays usable](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fbubble_after_save.webp) [brain_note_suggestion_bubble.mp4](https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fbrain_note_suggestion_bubble.mp4) <sub>To show artifacts inline, <a href="https://cursor.com/dashboard/cloud-agents#my-pull-requests">enable</a> in settings.</sub> <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-019fe687-99b1-76fd-80fa-dd213aecc497?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-019fe687-99b1-76fd-80fa-dd213aecc497&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent b4d62d2 commit f28cc95

24 files changed

Lines changed: 1514 additions & 49 deletions

CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,15 @@ The Agent tab must not inherit the admin MCP token. `/api/agent/session` mints a
269269
resilience but only ever handled a *moved ref*, re-issuing the identical refused
270270
request against a 429. A fallback that fails the same way as the thing it backs
271271
up is not a fallback.
272+
7. **Never offer a write the caller cannot enforce.** `SOUL.md` once asked
273+
"Should everyone on this database see this?" after every good answer, so
274+
Agent chat offered "save this as a shared DeepSQL brain note" to users
275+
without `canManageContent` and then 403'd. `get_brain_context` now stamps
276+
`callerCapabilities`; if `doNotOffer` includes `save_brain_note`, the
277+
agent must not mention it. MCP `save_brain_note` also fail-closes before
278+
the POST. Admins get a non-blocking suggestion bubble only after they
279+
correct or teach the Agent (`POST /brain/notes/propose` + accept) — a
280+
clean first answer stays quiet. Overlaps merge into one intent.
272281

273282
### Verification Anti-Patterns (do not repeat)
274283

agent/SOUL.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ You are **DeepSQL DBA**, an AI database performance assistant. You answer questi
22

33
**Lead with the answer.** You ground thoroughly with the tools, but you do **not** narrate that work in your reply. No "I checked / I joined…", no "Grounding used", no "Filters applied", no "Used:" footnotes, no column/filter walkthroughs. Answer with just the result — a number, a short ranked table, or a one-line sentence — and apply business rules silently. Tool steps already show what ran; don't repeat that in the bubble.
44

5-
After the answer you may offer **one short follow-up question** (a single line) when it helps the user go deeper. Do not stack multiple offers. If the user wants the SQL, the tables, or how you got there, they'll ask, and then you show it. Admit uncertainty instead of guessing; prefer one correct answer over a verbose survey.
5+
After the answer you may offer **one short follow-up question** (a single line) when it helps the user go deeper — a question they can answer, not an action they cannot take. Do not stack multiple offers. If the user wants the SQL, the tables, or how you got there, they'll ask, and then you show it. Admit uncertainty instead of guessing; prefer one correct answer over a verbose survey.
66

77
(Exception: the schema-consult flow in rule 8 — when proposing a table/migration you DO briefly state what already exists, because that's the point of the consult.)
88

@@ -24,25 +24,35 @@ After the answer you may offer **one short follow-up question** (a single line)
2424

2525
8. **Consult before you commit schema.** When the user says "add a table / track X / write a migration," STOP and run the brain consult (`get_brain_context``get_schema``list_business_rules``get_relationships``get_anti_patterns`). There is almost always an existing table or column to extend instead of duplicate. Narrate what you found before proposing DDL.
2626

27+
9. **Never offer an action the caller cannot enforce.** `get_brain_context` and
28+
`list_connections` carry `callerCapabilities`. If `doNotOffer` lists an
29+
action — especially `save_brain_note` — do not mention it, do not ask
30+
"should I save this", and do not render a Yes button. Answering a metric
31+
is not a request to persist it. The product UI may show a non-blocking
32+
save bubble after the user corrects or teaches a definition; leave that
33+
to the UI. Never volunteer it yourself.
34+
2735
## Remembering things — two different places
2836

29-
There are TWO planes of memory. Route every "remember this" to the right one:
37+
There are TWO planes of memory. Route a remember request only when the user
38+
explicitly asked to remember / pin / save a definition:
3039

3140
1. **Company brain context (shared).** Durable facts about the *data* — what a
3241
column means, a join path, a business definition, an accepted recommendation.
3342
These ground EVERYONE's answers on this connection. Save them with
34-
**`save_brain_note(connectionId, tableName, noteText, columnName?)`**.
35-
- "Accept this recommendation" / "remember this for the team" → review with
36-
**`list_brain_recommendations`**, then `save_brain_note` for each good one.
37-
- This is **admin-only** (manage-content) and audited. If the user lacks
38-
permission, the backend rejects it — say so, don't work around it.
43+
**`save_brain_note(connectionId, tableName, noteText, columnName?)`**
44+
**only if** `callerCapabilities.canWriteSharedBrainNotes` is true
45+
(`list_connections.canManageContent`).
46+
- If they asked to remember and they cannot write: tell them an admin with
47+
manage-content on this connection has to save it. Do not call the tool.
3948
2. **Individual preference (yours alone).** How *this* user likes answers
4049
formatted, a private shortcut, a personal default. That is a **DeepSQL
4150
skill** on the user's own profile — it does NOT belong in the shared brain.
4251
Never push a personal preference into `save_brain_note`.
4352

44-
When unsure which plane a request belongs to, ask: "Should everyone on this
45-
database see this, or just you?" Shared → brain note. Just you → DeepSQL skill.
53+
Do not volunteer a shared-brain save after answering a data question. Do not
54+
ask "should everyone on this database see this?" unless the user already asked
55+
to remember something **and** they can write shared notes.
4656

4757
## Skills
4858

agent/skills/bi-query/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Use when the user asks a question whose answer is **in the data** ("how many boo
2727

2828
6. **Run it** with `execute_sql(connectionId, sql, limit=…)`. Remember: default 100 rows, max 1000. For a total, `SELECT COUNT(*)` rather than counting a truncated result set.
2929

30-
7. **Answer only.** Reply with just the result — the number or a short ranked table — then optionally **one** short follow-up question. Apply business rules silently; do NOT append "Grounding used" / "Filters applied" / "Used:" / tool-narration / column-mapping sections. Only if the user asks how you got it do you show the tables, joins, and filters.
30+
7. **Answer only.** Reply with just the result — the number or a short ranked table — then optionally **one** short follow-up question the user can actually act on. Do **not** offer to save a shared brain note, apply an index, or run DDL/DML unless `get_brain_context.callerCapabilities` says they can. Apply business rules silently; do NOT append "Grounding used" / "Filters applied" / "Used:" / tool-narration / column-mapping sections. Only if the user asks how you got it do you show the tables, joins, and filters.
3131

3232
## Guardrails
3333

backend/src/main/java/com/dbaagent/controller/BrainController.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
import com.dbaagent.service.brain.core.BrainNoteService;
2929
import com.dbaagent.service.brain.core.BrainTaskService;
3030
import com.dbaagent.service.brain.core.NoteSuggestionService;
31+
import com.dbaagent.service.brain.core.BrainNoteProposalService;
32+
import com.dbaagent.dto.BrainNoteProposalRequest;
33+
import com.dbaagent.dto.BrainNoteProposalResponse;
3134
import com.dbaagent.service.QueryExecutorService;
3235
import com.dbaagent.service.SchemaSnapshotService;
3336
import com.dbaagent.service.brain.analysis.ColumnDisambiguationService;
@@ -94,6 +97,7 @@ public class BrainController {
9497
private final BrainNoteService brainNoteService;
9598
private final BrainTaskService brainTaskService;
9699
private final NoteSuggestionService noteSuggestionService;
100+
private final BrainNoteProposalService brainNoteProposalService;
97101
private final ColumnProfilingService columnProfilingService;
98102
private final ColumnDisambiguationService columnDisambiguationService;
99103
private final SchemaSnapshotService schemaSnapshotService;
@@ -240,6 +244,51 @@ public ResponseEntity<NoteSuggestionDTO.Response> getNoteSuggestions(
240244
}
241245
}
242246

247+
/**
248+
* Draft a shared-brain note from an Agent turn. Overlaps with existing notes
249+
* or business rules are merged into one intent. Read-only — does not persist.
250+
*/
251+
@PostMapping("/notes/propose")
252+
public ResponseEntity<BrainNoteProposalResponse> proposeNoteFromTurn(
253+
@org.springframework.web.bind.annotation.RequestBody BrainNoteProposalRequest request
254+
) {
255+
try {
256+
if (request == null || request.getConnectionId() == null || request.getConnectionId().isBlank()) {
257+
return ResponseEntity.badRequest().build();
258+
}
259+
accessControlService.assertCanReadConnectionContent(request.getConnectionId());
260+
return brainNoteProposalService.proposeFromTurn(request)
261+
.map(ResponseEntity::ok)
262+
.orElseGet(() -> ResponseEntity.noContent().build());
263+
} catch (ResponseStatusException e) {
264+
throw e;
265+
} catch (Exception e) {
266+
log.error("Error proposing a brain note from an agent turn", e);
267+
return ResponseEntity.internalServerError().build();
268+
}
269+
}
270+
271+
/**
272+
* Accept a proposed note. If it overlaps existing documentation, update that
273+
* row instead of creating a second copy of the same intent.
274+
*/
275+
@PostMapping("/notes/accept")
276+
public ResponseEntity<BrainNoteResponse> acceptNote(
277+
@org.springframework.web.bind.annotation.RequestBody BrainNoteRequest request
278+
) {
279+
try {
280+
accessControlService.assertCanManageConnectionContent(request.getConnectionId());
281+
return ResponseEntity.ok(brainNoteProposalService.accept(request));
282+
} catch (IllegalArgumentException e) {
283+
return ResponseEntity.badRequest().build();
284+
} catch (ResponseStatusException e) {
285+
throw e;
286+
} catch (Exception e) {
287+
log.error("Error accepting a brain note proposal", e);
288+
return ResponseEntity.internalServerError().build();
289+
}
290+
}
291+
243292
@GetMapping("/notes/{connectionId}")
244293
public ResponseEntity<List<BrainNoteResponse>> getNotes(
245294
@PathVariable String connectionId,
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.dbaagent.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Data;
5+
import lombok.NoArgsConstructor;
6+
7+
@Data
8+
@NoArgsConstructor
9+
@AllArgsConstructor
10+
public class BrainNoteProposalRequest {
11+
private String connectionId;
12+
private String question;
13+
private String answer;
14+
/** Previous assistant answer. Required for a proposal — clean first turns stay quiet. */
15+
private String priorAnswer;
16+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.dbaagent.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Data;
6+
import lombok.NoArgsConstructor;
7+
8+
@Data
9+
@Builder
10+
@NoArgsConstructor
11+
@AllArgsConstructor
12+
public class BrainNoteProposalResponse {
13+
private String scopeType;
14+
private String tableName;
15+
private String columnName;
16+
private String bubbleLabel;
17+
private String excerpt;
18+
private String proposedNoteText;
19+
/** NEW, MERGE, or SKIP (SKIP is omitted from the Agent UI). */
20+
private String action;
21+
private String existingNoteId;
22+
private String existingNoteText;
23+
private String overlapReason;
24+
}

0 commit comments

Comments
 (0)