@adrata/adrata-mcp 1.0.49 → 1.0.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/access/tiers.js CHANGED
@@ -254,6 +254,45 @@ export const TOOL_TIERS = {
254
254
  update_work_launch: TIERS.ENTERPRISE,
255
255
  add_work_item_to_launch: TIERS.ENTERPRISE,
256
256
  remove_work_item_from_launch: TIERS.ENTERPRISE,
257
+ // Threads: goals, indicators, decisions, ideas, discussion (tools/threads/).
258
+ list_goals: TIERS.ENTERPRISE,
259
+ get_goal_thread: TIERS.ENTERPRISE,
260
+ create_goal: TIERS.ENTERPRISE,
261
+ link_goal: TIERS.ENTERPRISE,
262
+ list_indicators: TIERS.ENTERPRISE,
263
+ create_indicator: TIERS.ENTERPRISE,
264
+ record_indicator_reading: TIERS.ENTERPRISE,
265
+ list_decisions: TIERS.ENTERPRISE,
266
+ get_decision: TIERS.ENTERPRISE,
267
+ record_decision: TIERS.ENTERPRISE,
268
+ set_decision_status: TIERS.ENTERPRISE,
269
+ record_decision_outcome: TIERS.ENTERPRISE,
270
+ take_decision_position: TIERS.ENTERPRISE,
271
+ link_decision: TIERS.ENTERPRISE,
272
+ get_decision_graph: TIERS.ENTERPRISE,
273
+ relate_decisions: TIERS.ENTERPRISE,
274
+ unrelate_decisions: TIERS.ENTERPRISE,
275
+ list_ideas: TIERS.ENTERPRISE,
276
+ get_idea: TIERS.ENTERPRISE,
277
+ capture_idea: TIERS.ENTERPRISE,
278
+ set_idea_state: TIERS.ENTERPRISE,
279
+ link_idea: TIERS.ENTERPRISE,
280
+ list_discussion: TIERS.ENTERPRISE,
281
+ list_thread_changes: TIERS.ENTERPRISE,
282
+ post_discussion: TIERS.ENTERPRISE,
283
+ resolve_discussion: TIERS.ENTERPRISE,
284
+ // Propositions, the graph read and document filing (tools/threads/work-proposition-tools.js).
285
+ list_propositions: TIERS.ENTERPRISE,
286
+ get_proposition: TIERS.ENTERPRISE,
287
+ get_proposition_calibration: TIERS.ENTERPRISE,
288
+ state_proposition: TIERS.ENTERPRISE,
289
+ record_proposition_reading: TIERS.ENTERPRISE,
290
+ set_proposition_status: TIERS.ENTERPRISE,
291
+ link_proposition: TIERS.ENTERPRISE,
292
+ unlink_proposition: TIERS.ENTERPRISE,
293
+ get_thread_graph: TIERS.ENTERPRISE,
294
+ file_document: TIERS.ENTERPRISE,
295
+ unfile_document: TIERS.ENTERPRISE,
257
296
  set_work_board_column_wip_limit: TIERS.ENTERPRISE,
258
297
  attach_work_item_qa_evidence: TIERS.ENTERPRISE,
259
298
  verify_work_item_qa_evidence_playback: TIERS.ENTERPRISE,
package/api-bridge.js CHANGED
@@ -224,6 +224,20 @@ const ALLOWED_PREFIXES = [
224
224
  '/api/v1/work-items',
225
225
  // A launch is a lens over the boards; its writes are board writes.
226
226
  '/api/v1/work-launches',
227
+ // The threads family: goals, indicators, decisions, ideas, the discussion
228
+ // under them and their field-change log. None is a board citizen; their
229
+ // writes are board-family writes (read:tasks / write:tasks), and every one
230
+ // is listed separately because matchesPathPrefix is segment-boundary matched.
231
+ '/api/v1/work-decisions',
232
+ '/api/v1/work-discussions',
233
+ '/api/v1/work-goals',
234
+ '/api/v1/work-ideas',
235
+ '/api/v1/work-indicators',
236
+ '/api/v1/work-propositions',
237
+ '/api/v1/work-thread-changes',
238
+ // The one graph read, any root (`/work-threads/graph`); the family name
239
+ // is `work-threads`, listed separately for the same segment-boundary reason.
240
+ '/api/v1/work-threads',
227
241
  // The containers above the cards (initiative / epic / feature) and the
228
242
  // "add this to the roadmap" verb. Creating an epic proposal is a governed
229
243
  // write like any other; the strategic commitment itself still moves through
@@ -357,6 +371,17 @@ const PATH_WRITE_SCOPES = [
357
371
  ['/api/v1/work-items', 'write:tasks'],
358
372
  ['/api/v1/work-board-rollups', 'write:tasks'],
359
373
  ['/api/v1/work-launches', 'write:tasks'],
374
+ // The threads family maps onto the same scope family as the boards it
375
+ // hangs beside; without these rows a thread write previews as
376
+ // `requiredScope: "unknown (no mapping for this path)"`.
377
+ ['/api/v1/work-goals', 'write:tasks'],
378
+ ['/api/v1/work-indicators', 'write:tasks'],
379
+ ['/api/v1/work-decisions', 'write:tasks'],
380
+ ['/api/v1/work-ideas', 'write:tasks'],
381
+ ['/api/v1/work-discussions', 'write:tasks'],
382
+ ['/api/v1/work-thread-changes', 'write:tasks'],
383
+ ['/api/v1/work-propositions', 'write:tasks'],
384
+ ['/api/v1/work-threads', 'write:tasks'],
360
385
  ];
361
386
 
362
387
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrata/adrata-mcp",
3
- "version": "1.0.49",
3
+ "version": "1.0.50",
4
4
  "description": "Adrata MCP Server \u2014 connect Claude Code, Codex, Gemini, Cursor, and other AI tools to your CRM. About 275 tools registered at startup for companies, people, deals, actions, buyer groups, warm intros, webhooks and intelligence, plus 65 more behind eight named toolsets you load with enable_toolset.",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
- "test": "node --test analytics.test.js server.test.js api-bridge.test.js http/edge-block.test.js http/rate-limit.test.js audit-flush.test.js buyer-group-writes.test.js note-writes.test.js mcp-spec.test.js packaging.test.js product-profile.test.js security.test.js security.cap-contract.test.js tool-annotations.test.js toolsets.test.js toolsets/communications.test.js toolsets/prospecting.test.js access/auth.test.js access/oauth-callback.test.js access/oauth-session.test.js access/oauth-capabilities.test.js access/oauth-scope-grantability.test.js scripts/local-dev-server.test.js tools/competitive-coverage.test.js tools/describe-count.test.js tools/email-tools.test.js tools/scheduling.test.js tools/work-board-tools.test.js tools/launches/work-launch-tools.test.js tools/work-hub/audit.test.js tools/work-hub/field-changes.test.js tools/work-hub/criteria-quality.test.js tools/roadmap-tools.test.js tools/provisioning/onboarding-tools.test.js tools/source-control/connection-tools.test.js governance/money.test.js http/rate-limit-policy.test.js"
12
+ "test": "node --test analytics.test.js server.test.js api-bridge.test.js http/edge-block.test.js http/rate-limit.test.js audit-flush.test.js buyer-group-writes.test.js note-writes.test.js mcp-spec.test.js packaging.test.js product-profile.test.js security.test.js security.cap-contract.test.js tool-annotations.test.js toolsets.test.js toolsets/communications.test.js toolsets/prospecting.test.js access/auth.test.js access/oauth-callback.test.js access/oauth-session.test.js access/oauth-capabilities.test.js access/oauth-scope-grantability.test.js scripts/local-dev-server.test.js tools/competitive-coverage.test.js tools/describe-count.test.js tools/email-tools.test.js tools/scheduling.test.js tools/work-board-tools.test.js tools/launches/work-launch-tools.test.js tools/threads/work-thread-tools.test.js tools/work-hub/audit.test.js tools/work-hub/field-changes.test.js tools/work-hub/criteria-quality.test.js tools/roadmap-tools.test.js tools/provisioning/onboarding-tools.test.js tools/source-control/connection-tools.test.js governance/money.test.js http/rate-limit-policy.test.js"
13
13
  },
14
14
  "keywords": [
15
15
  "mcp",
package/server.js CHANGED
@@ -54,6 +54,7 @@ import { registerEnterpriseTools } from './tools/enterprise-tools.js';
54
54
  import { registerEmailTools } from './tools/email-tools.js';
55
55
  import { registerWorkBoardTools } from './tools/work-board-tools.js';
56
56
  import { registerWorkLaunchTools } from './tools/launches/work-launch-tools.js';
57
+ import { registerWorkThreadTools } from './tools/threads/work-thread-tools.js';
57
58
  import { registerSourceControlTools } from './tools/source-control/connection-tools.js';
58
59
  import { registerRoadmapTools } from './tools/roadmap-tools.js';
59
60
  import { registerProvisioningTools } from './tools/provisioning/onboarding-tools.js';
@@ -2437,6 +2438,20 @@ registerWorkLaunchTools(server, {
2437
2438
  getGrantedScope: () => loadTokens()?.scope,
2438
2439
  });
2439
2440
 
2441
+ // ===== THREAD TOOLS =====
2442
+ // Goals, propositions, indicators, decisions, ideas and the discussion under
2443
+ // them — the threads family — plus the one graph read and Galaxy document
2444
+ // filing. Nothing is a board citizen, progress derived on read. Same governed
2445
+ // contract; see tools/threads/work-thread-tools.js.
2446
+ registerWorkThreadTools(server, {
2447
+ z,
2448
+ api,
2449
+ ok,
2450
+ validateApiBridgeRequest,
2451
+ buildMutationHeaders,
2452
+ getGrantedScope: () => loadTokens()?.scope,
2453
+ });
2454
+
2440
2455
  registerSourceControlTools(server, {
2441
2456
  z,
2442
2457
  api,
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "com.adrata/adrata-mcp",
4
4
  "description": "Adrata revenue-intelligence MCP server: companies, people, opportunities, actions, buyer groups, enrichment, email, and workspace operations for AI agents.",
5
5
  "status": "active",
6
- "version": "1.0.49",
6
+ "version": "1.0.50",
7
7
  "websiteUrl": "https://adrata.com/developers",
8
8
  "repository": {
9
9
  "url": "https://github.com/adrata/adrata",
@@ -15,7 +15,7 @@
15
15
  "registryType": "npm",
16
16
  "registryBaseUrl": "https://registry.npmjs.org",
17
17
  "identifier": "@adrata/adrata-mcp",
18
- "version": "1.0.49",
18
+ "version": "1.0.50",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  },
@@ -0,0 +1,170 @@
1
+ ---
2
+ name: capture-to-idea
3
+ description: Turn something you just read or heard — a customer call, a competitor's launch, a keynote, an audit finding, a note from the owner — into the right Starfield thread record with its source attached. Use when someone says "capture this", "we should do X", "add this to the ideas", or when an audit turns up a gap worth remembering rather than a bug worth fixing.
4
+ ---
5
+
6
+ # Capture to idea
7
+
8
+ The gap between "somebody said something smart" and "we did it" is the
9
+ record. A Slack line evaporates; a card is the wrong size for it; an epic is
10
+ too big a commitment for a hunch. The threads family exists for exactly this
11
+ middle: an **idea** with the vector it arrived on and the source it came from,
12
+ so six weeks later the next person can go back to the transcript instead of
13
+ to your memory of it.
14
+
15
+ This skill is about spending three minutes now so the capture is findable,
16
+ attributable and honest.
17
+
18
+ ## First: which noun is it?
19
+
20
+ Four nouns share the thread tools, and putting a thing in the wrong one is
21
+ the most common mistake. Decide before you write:
22
+
23
+ | You are holding… | It is a… | Tool |
24
+ |---|---|---|
25
+ | A thing we could build or do, whose value is still an argument | **idea** | `capture_idea` |
26
+ | An outcome we are committing to, or a bet on how to reach one | **goal** / **strategy** | `create_goal` |
27
+ | A ruling — a choice among alternatives that others must now live with | **decision** | `record_decision` |
28
+ | A number that says whether a goal is on track, or a new reading of one | **indicator** / reading | `create_indicator`, `record_indicator_reading` |
29
+ | A claim about the world we are acting on, that evidence could show false | **proposition** | `state_proposition` |
30
+ | A bug, an outage, a thing that is broken right now | a **card** | the `incident-to-card` skill, not this one |
31
+
32
+ Two tells. If the sentence has "we should…" in it, it is an idea until someone
33
+ with the authority to commit says so. If the sentence has "we will no longer…"
34
+ or "from now on…", it is a decision, and it needs the alternatives it beat and
35
+ who decided. A number with no goal behind it is not an indicator yet; capture
36
+ the idea that there should be a goal.
37
+
38
+ ## Read the source before you write anything
39
+
40
+ Read the whole thing — the transcript, the doc, the thread, the audit — not the
41
+ summary of it. Then extract, without inventing:
42
+
43
+ 1. **The outcome** — what would be true if this idea were realized, as a
44
+ statement. Not the feature. "Sellers see which buyer-group member is the
45
+ real decision-maker before the first call", not "add a decision-maker badge".
46
+ 2. **Who it serves** — the audience. `seller | manager | leader` are the people
47
+ Adrata serves; `builder` is us building Adrata, Starfield and Portals;
48
+ `company` is the whole business. Pick the one the source actually names.
49
+ 3. **Where it came from** — the vector, chosen honestly (below).
50
+ 4. **The source itself** — the specific, re-findable thing: the URL, the
51
+ `company/...` path, the customer's name and the call date, the transcript
52
+ title, the audit's name. "Ross said" is not a source; the ideas notepad path
53
+ and the heading is.
54
+ 5. **What it links to** — an existing goal it serves, an epic or card that
55
+ already realizes part of it, a decision that constrains it, another idea it
56
+ is a variant of.
57
+
58
+ If you cannot state the outcome from the source, write what the source said
59
+ and mark the outcome as unstated. A confident outcome the source never
60
+ expressed is your idea wearing their name.
61
+
62
+ ## Pick the vector honestly
63
+
64
+ `vector` is where the idea arrived from, and it is required because the
65
+ distribution of vectors is itself a signal — a roadmap fed only by `owner` and
66
+ `internal_audit` is a roadmap nobody outside asked for.
67
+
68
+ - `owner` — the owner said it (the ideas notepad, a direct message, a ruling in
69
+ passing). Only when he actually did.
70
+ - `customer` — a paying or prospective customer said it, named. Not "customers
71
+ probably want".
72
+ - `buyer_demand` — a pattern across buyers: several deals, a lost-deal reason,
73
+ an RFP requirement. Name the deals.
74
+ - `competitor` — a competitor shipped or announced it. Name the release.
75
+ - `keynote` — it came from a talk, a paper, a conference. Name the talk.
76
+ - `internal_audit` — an audit, a review or a measurement of our own product
77
+ turned it up. Name the audit.
78
+ - `technology` — a new capability made it possible (a model, an API, a
79
+ platform change). Name it.
80
+ - `regulation` — a law, a compliance regime, a platform policy requires it.
81
+
82
+ The flattering vector is usually `customer`. If one customer mentioned it once
83
+ in passing, it is `customer` with that customer named and a body that says
84
+ "mentioned once". Do not upgrade it to `buyer_demand`.
85
+
86
+ ## Check before you create
87
+
88
+ 1. `list_ideas` filtered by the audience, and skim titles. A near-duplicate
89
+ idea is a list nobody reads; if one exists, `post_discussion` on it with the
90
+ new source and `link_idea` if the new source connects it to something.
91
+ 2. `list_goals` for the goal it serves. Most ideas serve one; say which.
92
+ 3. `list_work_scopes` when the idea sounds like an epic that may already exist.
93
+ If an epic already realizes it, the idea is still worth capturing (the
94
+ source and the vector are new information), linked to that scope.
95
+
96
+ ## Writing it
97
+
98
+ **Title**: the outcome, at most 160 characters. Someone scanning `/ideas`
99
+ must be able to tell what would be different without opening it.
100
+
101
+ **Body**: what it is, who it is for, why now, what it would take — under plain
102
+ headings. Quote the source where you can, verbatim, in a block. Say what you
103
+ do not know. Do not write acceptance criteria; an idea is not a card.
104
+
105
+ ## Filing it
106
+
107
+ 1. `capture_idea` with `title`, `body`, `vector`, `source`, `audience`. The
108
+ idea lands in state `captured`. Do not set a state; exploring is a later
109
+ act with its own reason.
110
+ 2. `link_idea` with `scopeId`, `workItemId` or `relatedIdeaId` for each
111
+ connection you found, one call each, with a reason in a sentence.
112
+ 3. `link_goal` with the `goalId` and `ideaId` if it serves a goal.
113
+ 4. If a decision constrains it, `link_decision` from that decision to the idea.
114
+
115
+ If you decided it was a **goal**: `create_goal` (level `goal`, or `strategy`
116
+ with `parentGoalId`; a strategy is a bet, so give its hypothesis, falsifier and
117
+ confidence). If it was a **decision**: `record_decision` with the alternatives
118
+ and a `door`; it lands `proposed`, and people take positions on it. If it was
119
+ an **indicator**: `create_indicator` with the definition that stops two people
120
+ reading two numbers, then `record_indicator_reading` for the number you have.
121
+
122
+ ## Propositions: when the source is a claim, not a thing
123
+
124
+ Some sources are not a thing to build. They are a claim about the world that
125
+ our plans rest on: "enterprise buyers will pay for provable control", "sellers
126
+ will not type into a CRM", "the three rocks are the fastest route to $1M ARR".
127
+ Filing that as an idea loses it; the claim is what a strategy's hypothesis and
128
+ a decision's reversal condition were both privately pointing at. File a
129
+ **proposition** instead:
130
+
131
+ 1. `list_propositions` first. The same claim stated twice is two confidence
132
+ histories nobody can reconcile; if it exists, `record_proposition_reading`
133
+ with the new evidence and its source.
134
+ 2. `state_proposition` with the `statement` as one sentence, the `rationale`,
135
+ a `confidence` (your honest probability, 0-1; it becomes the first reading
136
+ and is scored later), the `falsifier` — the observation that would show it
137
+ false, required, because a claim with no falsifier is a slogan — and the
138
+ `audience`.
139
+ 3. `link_proposition` to each goal, decision, idea or indicator that rests on
140
+ it, with a reason. That is the `assumes` edge; when the claim is falsified,
141
+ everything linked lights up.
142
+ 4. If the source is a document — the audit, the transcript, the deck — file it
143
+ with `file_document` (family `proposition`, role `evidence` or `source`).
144
+
145
+ Never edit a proposition's confidence: every change is a reading with its
146
+ evidence. A source that argues *against* a claim we hold is a reading with a
147
+ lower confidence, not a new proposition.
148
+
149
+ ## Promotion is a separate act
150
+
151
+ Do not create an epic to capture an idea. When an idea has earned an epic,
152
+ that is `add_to_roadmap` (it creates the proposed scope and hands you the git
153
+ half to write), then `link_idea` with the new `scopeId`, then `set_idea_state`
154
+ to `committed` with the reason. Three verbs, because they are three decisions
155
+ someone can disagree with separately.
156
+
157
+ ## Writes
158
+
159
+ Every thread write is governed: it previews by default. Show the human the
160
+ record you are about to file — title, body, vector, source, audience, and the
161
+ links — and file it only after they say yes, with `dryRun: false`,
162
+ `approved: true`, a `reason`, and an `idempotencyKey`. Reuse the same key on
163
+ retry so a flaky connection does not capture the same idea twice.
164
+
165
+ ## After filing
166
+
167
+ Report the idea id, its vector and source, and what it was linked to. If
168
+ reading the source surfaced a second thing — a bug, a decision nobody
169
+ recorded, a number nobody tracks — say so as a separate line, clearly marked
170
+ as your observation rather than as something the source said.
@@ -129,6 +129,48 @@ const DOMAIN_OVERRIDES = {
129
129
  update_work_launch: 'board',
130
130
  add_work_item_to_launch: 'board',
131
131
  remove_work_item_from_launch: 'board',
132
+ // Threads: goals, indicators, decisions, ideas, discussion (tools/threads/).
133
+ // Board pack because they hang beside the boards and share its scope family;
134
+ // without an override `list_goals` would fall to the general catch-all.
135
+ list_goals: 'board',
136
+ get_goal_thread: 'board',
137
+ create_goal: 'board',
138
+ link_goal: 'board',
139
+ list_indicators: 'board',
140
+ create_indicator: 'board',
141
+ record_indicator_reading: 'board',
142
+ list_decisions: 'board',
143
+ get_decision: 'board',
144
+ record_decision: 'board',
145
+ set_decision_status: 'board',
146
+ record_decision_outcome: 'board',
147
+ take_decision_position: 'board',
148
+ link_decision: 'board',
149
+ get_decision_graph: 'board',
150
+ relate_decisions: 'board',
151
+ unrelate_decisions: 'board',
152
+ list_ideas: 'board',
153
+ get_idea: 'board',
154
+ capture_idea: 'board',
155
+ set_idea_state: 'board',
156
+ link_idea: 'board',
157
+ list_discussion: 'board',
158
+ list_thread_changes: 'board',
159
+ post_discussion: 'board',
160
+ resolve_discussion: 'board',
161
+ // Propositions (the fifth noun), the one graph read and Galaxy document
162
+ // filing live in the same family (tools/threads/work-proposition-tools.js).
163
+ list_propositions: 'board',
164
+ get_proposition: 'board',
165
+ get_proposition_calibration: 'board',
166
+ state_proposition: 'board',
167
+ record_proposition_reading: 'board',
168
+ set_proposition_status: 'board',
169
+ link_proposition: 'board',
170
+ unlink_proposition: 'board',
171
+ get_thread_graph: 'board',
172
+ file_document: 'board',
173
+ unfile_document: 'board',
132
174
  list_forms: 'forms',
133
175
  get_form: 'forms',
134
176
  create_form: 'forms',
@@ -234,6 +276,14 @@ const DESTRUCTIVE = new Set([
234
276
  // destructiveHint — a host would not have confirmation-gated a delete.
235
277
  'manage_company', 'manage_person', 'manage_opportunity', 'manage_activity',
236
278
  'manage_buyer_group',
279
+ // Decision graph: unrelate_decisions DELETEs an edge and the reason recorded
280
+ // on it. The name has no `delete_` prefix (it is the graph's own verb), so
281
+ // it is listed here to be confirmation-gated; it stays idempotent below.
282
+ 'unrelate_decisions',
283
+ // Same shape for the proposition's assumes edge and a Galaxy document
284
+ // filing: each DELETE drops the row and the reason recorded on it, and
285
+ // neither name carries a delete_ prefix. Both stay idempotent below.
286
+ 'unlink_proposition', 'unfile_document',
237
287
  ]);
238
288
 
239
289
  /** True when a tool is destructive (irreversible delete, spend, or send). */
@@ -265,6 +315,31 @@ const IDEMPOTENT_WRITES = new Set([
265
315
  // A repeat block is the same edge (the pair is unique), and a repeat
266
316
  // unblock deletes a row that is already gone. Neither compounds.
267
317
  'block_work_item', 'unblock_work_item',
318
+ // Threads. link_goal / link_decision / link_idea are ON CONFLICT upserts on
319
+ // a join table keyed by the pair: replaying the same link rewrites the same
320
+ // row's reason and adds no edge. take_decision_position is an upsert per
321
+ // (decision, user): the second call replaces the first position, so a retry
322
+ // lands on the same state. resolve_discussion sets or clears resolved_at on
323
+ // one root post; a repeat sets what is already set. Deliberately NOT here:
324
+ // record_indicator_reading, post_discussion, create_goal, create_indicator,
325
+ // record_decision and capture_idea each APPEND a row per call, and
326
+ // set_idea_state / set_decision_status each append a history row — see
327
+ // NON_IDEMPOTENT_WRITES.
328
+ 'link_goal', 'link_decision', 'link_idea', 'take_decision_position', 'resolve_discussion',
329
+ // Decision graph edges are keyed by (decision, related decision, relation):
330
+ // a repeat relate rewrites the same edge's reason, a repeat unrelate deletes
331
+ // an edge already gone. Neither compounds. unrelate is its own verb rather
332
+ // than a flag on relate so the removal is visible in the tool NAME — and it
333
+ // is in DESTRUCTIVE above (it drops the edge and its reason), so it is
334
+ // destructive AND idempotent, like every delete/revoke.
335
+ 'relate_decisions', 'unrelate_decisions',
336
+ // Propositions: link_proposition is an ON CONFLICT upsert keyed by
337
+ // (proposition, target type, target id), and file_document by (target,
338
+ // document): a repeat rewrites the same row's reason (and role). Their
339
+ // removals delete a row already gone on a repeat. Deliberately NOT here:
340
+ // state_proposition and record_proposition_reading each APPEND a row, and
341
+ // set_proposition_status appends a history row — see NON_IDEMPOTENT_WRITES.
342
+ 'link_proposition', 'unlink_proposition', 'file_document', 'unfile_document',
268
343
  'add_work_item_acceptance_criterion',
269
344
  // (delete_work_item needs no entry here either: DESTRUCTIVE_PATTERN matches
270
345
  // the `delete_` prefix, so a host confirmation-gates it on its NAME. That is
@@ -308,6 +383,16 @@ const NON_IDEMPOTENT_WRITES = new Set([
308
383
  'enable_toolset', 'track_conversion', 'log_interaction', 'test_webhook',
309
384
  'replay_webhook_delivery', 'upgrade_account',
310
385
  'release_work_item_qa_pass',
386
+ // Threads: every one of these appends a row per call — a reading, a post, a
387
+ // goal, an indicator, a decision, an idea, a state-history row, or a written-
388
+ // once outcome — so a retry under a FRESH idempotency key compounds.
389
+ 'create_goal', 'create_indicator', 'record_indicator_reading',
390
+ 'record_decision', 'set_decision_status', 'record_decision_outcome',
391
+ 'capture_idea', 'set_idea_state', 'post_discussion',
392
+ // Propositions: a stated claim, a reading (the confidence history IS the
393
+ // calibration record, so a duplicate reading is a real distortion), and a
394
+ // status transition with its history row.
395
+ 'state_proposition', 'record_proposition_reading', 'set_proposition_status',
311
396
  ]);
312
397
 
313
398
  // Tools that reach beyond the workspace (external enrichment, web, providers).