@anchrd/intel-api 0.25.0 → 0.26.0

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.
@@ -7,9 +7,33 @@ import { documentLinkTargets } from "../nodes/document-links/document-links.js";
7
7
  import { parseCsv } from "../shared/csv/csv.js";
8
8
  import { IntelError } from "../shared/intel-error/intel-error.js";
9
9
  import { plainTitle } from "../shared/plain-title/plain-title.js";
10
- // What every bundle leaves out, by decision rather than by accident (#136). The manifest says so,
11
- // because a backup that is silent about what it does not hold will be trusted with exactly that.
12
- const Excluded = ["version-history", "grants", "flow-runs", "archived-nodes"];
10
+ /**
11
+ * What every bundle leaves out, by decision rather than by accident (#136, ADR-0006). The manifest
12
+ * says so, because a backup that is silent about what it does not hold will be trusted with exactly
13
+ * that.
14
+ *
15
+ * ⚠️ ONE list, read by both halves of the round trip: the export writes it into `manifest.json`,
16
+ * and the import answers with it in `BundleImportResult.excluded` (#433). The import does NOT read
17
+ * the word back out of the manifest it was handed — a bundle claiming `excluded: []` would
18
+ * otherwise make the import report that nothing was left behind, and a naked folder carries no
19
+ * manifest to ask at all. Every one of the five holds for every import this build performs: a node
20
+ * arrives with `sequence: 1` and nothing older, an import mints no grants, it starts no runs,
21
+ * nothing it creates is archived, and a published flow's graph is the one that was published.
22
+ *
23
+ * ⚠️ `unpublished-flow-changes` is a CONSTANT of the build like the other four, not a report about
24
+ * this particular bundle (#585). It says what a bundle never carries — the edits made to a flow
25
+ * after it was published — in the same way `version-history` says so for a node with a single
26
+ * version. A list that named only the losses this one export happens to suffer would be a different
27
+ * kind of statement, and the import could not make it at all: it answers before it knows what is in
28
+ * the zip, and from a naked folder it never finds out.
29
+ */
30
+ const Excluded = [
31
+ "version-history",
32
+ "grants",
33
+ "flow-runs",
34
+ "archived-nodes",
35
+ "unpublished-flow-changes",
36
+ ];
13
37
  /**
14
38
  * A title as a file name. Titles are free text and zip paths are not: a separator would move the
15
39
  * entry, a control character corrupts the archive listing, and a trailing dot breaks extraction on
@@ -498,6 +522,27 @@ export function createBundle(deps) {
498
522
  : { type: "text", load: textLoader(version.contentKey) },
499
523
  };
500
524
  }
525
+ /**
526
+ * Which graph of a flow travels: the PUBLISHED one when the flow has one, the draft otherwise.
527
+ * Three call sites read it through this function rather than spelling the fallback out, because
528
+ * it is one decision and not three (#585).
529
+ *
530
+ * ⚠️ For a flow that was published and edited afterwards, this deliberately leaves the newer draft
531
+ * behind — and that is NOT the `version-history` case, which is why `Excluded` names it with a
532
+ * word of its own. The draft is the newest state there is; what decides against it is that the
533
+ * import cannot carry both. It creates rather than restores (ADR-0006): every flow arrives as a
534
+ * draft with one version and `publishedVersionId: null`, so "published v1 plus draft v2" has no
535
+ * shape on the far side, and carrying both would be flow version history — the thing that ADR
536
+ * refuses for exactly the reasons it lists.
537
+ *
538
+ * Of the two single answers the published one wins because it is the one that was IN EFFECT. A
539
+ * draft need never have been validated — publishing is what checks a graph — so moving the draft
540
+ * instead would replace a running process with somebody's unfinished edit, silently, at the one
541
+ * moment nobody can compare against the source any more.
542
+ */
543
+ function exportedVersionId(flow) {
544
+ return flow.publishedVersionId ?? flow.currentVersionId;
545
+ }
501
546
  function plannedFlow(flow, version, directory, used) {
502
547
  const path = `${directory}${uniqueName(used, sanitizeName(flow.title), ".json")}`;
503
548
  return {
@@ -547,9 +592,7 @@ export function createBundle(deps) {
547
592
  if (actor.canReadFlows) {
548
593
  const folderIds = new Set(rows.filter((row) => row.node.kind === "folder").map((row) => row.node.id));
549
594
  const visible = (await deps.flows.listVisible(asFlowActor(actor))).filter((flow) => flow.parentId === null ? rootId === null : folderIds.has(flow.parentId));
550
- const versionIds = visible
551
- .map((flow) => flow.publishedVersionId ?? flow.currentVersionId)
552
- .filter((id) => id !== null);
595
+ const versionIds = visible.map(exportedVersionId).filter((id) => id !== null);
553
596
  for (const version of await deps.flows.getVersions(versionIds)) {
554
597
  flowVersions.set(version.id, version);
555
598
  }
@@ -591,7 +634,7 @@ export function createBundle(deps) {
591
634
  continue;
592
635
  }
593
636
  if (item.flow !== null) {
594
- const versionId = item.flow.publishedVersionId ?? item.flow.currentVersionId;
637
+ const versionId = exportedVersionId(item.flow);
595
638
  entries.push(plannedFlow(item.flow, versionId === null ? undefined : flowVersions.get(versionId), directory, used));
596
639
  }
597
640
  }
@@ -823,6 +866,9 @@ export function createBundle(deps) {
823
866
  flows: typeof metadata.flows === "number" ? metadata.flows : 0,
824
867
  rootNodeIds: Array.isArray(metadata.rootNodeIds) ? metadata.rootNodeIds : [],
825
868
  replayed: true,
869
+ // Not read out of the stored metadata: a replay answers for an import this build performed,
870
+ // and the list is what this build leaves behind rather than something an earlier row said.
871
+ excluded: Excluded,
826
872
  });
827
873
  }
828
874
  return {
@@ -840,7 +886,7 @@ export function createBundle(deps) {
840
886
  if (!flow || flow.archivedAt !== null) {
841
887
  throw new IntelError(404, "flow_not_found", "Flow was not found");
842
888
  }
843
- const versionId = flow.publishedVersionId ?? flow.currentVersionId;
889
+ const versionId = exportedVersionId(flow);
844
890
  const version = versionId === null ? undefined : (await deps.flows.getVersions([versionId]))[0];
845
891
  const entries = [plannedFlow(flow, version, "", new Set())];
846
892
  // A single flow's bundle is rooted at the flow itself: the manifest names it as the root the
@@ -1124,7 +1170,13 @@ export function createBundle(deps) {
1124
1170
  for (const version of versions) {
1125
1171
  await deps.indexing.enqueue(version.id);
1126
1172
  }
1127
- return { nodes: nodes.length, flows: flows.length, rootNodeIds, replayed: false };
1173
+ return {
1174
+ nodes: nodes.length,
1175
+ flows: flows.length,
1176
+ rootNodeIds,
1177
+ replayed: false,
1178
+ excluded: Excluded,
1179
+ };
1128
1180
  },
1129
1181
  };
1130
1182
  }
@@ -1,4 +1,5 @@
1
1
  import { flowNodeLayer } from "@anchrd/intel-contract/flow";
2
+ import { callersPhrase, namedOrCounted } from "../shared/callers/callers.js";
2
3
  import { requireFutureExpiry } from "../shared/grant-expiry/grant-expiry.js";
3
4
  import { IntelError } from "../shared/intel-error/intel-error.js";
4
5
  import { plainTitle } from "../shared/plain-title/plain-title.js";
@@ -111,20 +112,28 @@ function toolStepDetail(missing) {
111
112
  ? `You do not reach this tool server in the portal: ${missing.join(", ")}`
112
113
  : `You do not reach these tool servers in the portal: ${missing.join(", ")}`;
113
114
  }
115
+ /**
116
+ * ⚠️ How every refusal on this file names a node: by the label the canvas draws, because the id
117
+ * appears nowhere on screen. `Node n-doc is a link and must be attached to a step` sent an author
118
+ * looking for a string the editor never draws, and the way to the cause led through the contract
119
+ * schema instead of through the sentence (#508). The id stays beside it: a graph arrives over MCP
120
+ * as readily as from the canvas, and there the id is what the caller holds.
121
+ *
122
+ * ⚠️ One function rather than one spelling per call site (#551): the publish-time refusals name a
123
+ * node too, and two places building the same string by hand is how the two halves of one sentence
124
+ * drift while both keep looking right.
125
+ */
126
+ function nodeName(node) {
127
+ return `"${node.label}" (${node.id})`;
128
+ }
114
129
  export function compileFlow(graph) {
115
130
  const nodes = new Map();
116
- // ⚠️ A refusal names a node the way the canvas does — by its label — because the id appears
117
- // nowhere on screen. `Node n-doc is a link and must be attached to a step` sent an author looking
118
- // for a string the editor never draws, and the way to the cause led through the contract schema
119
- // instead of through the sentence (#508). The id stays beside it: a graph arrives over MCP as
120
- // readily as from the canvas, and there the id is what the caller holds.
121
- //
122
131
  // ⚠️ It stands ABOVE the loop that fills the map on purpose, so the duplicate-id refusal reaches
123
132
  // it too — that one names the node the map already holds, which is the FIRST of the two. An id
124
133
  // nothing knows falls back to the bare id, which is what the dangling-edge case wants anyway.
125
134
  const named = (id) => {
126
135
  const node = nodes.get(id);
127
- return node ? `"${node.label}" (${id})` : id;
136
+ return node ? nodeName(node) : id;
128
137
  };
129
138
  for (const node of graph.nodes) {
130
139
  if (nodes.has(node.id))
@@ -314,14 +323,14 @@ function decodeCursor(cursor) {
314
323
  return { createdAt, id };
315
324
  }
316
325
  // ⚠️ Named where the reader may see it, counted where they may not — the same rule the folder's
317
- // refusal follows (`callersDetail` in nodes.ts): a refusal must not become a way of learning that a
318
- // flow one cannot see exists (ADR-0004 §3).
326
+ // refusal follows, out of the same place (`callersPhrase`): a refusal must not become a way of
327
+ // learning that a flow one cannot see exists (ADR-0004 §3).
328
+ //
329
+ // ⚠️ The SENTENCE stays its own, and that is deliberate (#593): the folder's says a grant cannot be
330
+ // narrowed yet, this one says a flow cannot be deleted for good. Same facts, different consequence
331
+ // — a single parameterised sentence would save a string and cost each refusal its reason.
319
332
  function callersDetail(callers) {
320
- const named = callers.visible.map((title) => `“${title}”`).join(", ");
321
- const rest = callers.hidden === 0
322
- ? ""
323
- : `${named ? " and " : ""}${callers.hidden} more flow${callers.hidden === 1 ? "" : "s"} you cannot see`;
324
- return `Published flows still call this one: ${named}${rest}. Change or unpublish them first.`;
333
+ return `Published flows still call this one: ${callersPhrase(callers)}. Change or unpublish them first.`;
325
334
  }
326
335
  export function createFlows(deps) {
327
336
  async function requireFlow(actor, flowId) {
@@ -1372,9 +1381,13 @@ export function createFlows(deps) {
1372
1381
  throw new IntelError(409, "flow_not_archived", "Only an archived flow can be deleted for good. Archive it first.");
1373
1382
  }
1374
1383
  // A published caller would break at RUN TIME, in front of somebody who did not order this.
1384
+ // ⚠️ The callers travel as DATA beside the sentence (#593, the second half of #448): the
1385
+ // archive shows this refusal, and a German or Spanish screen could otherwise do nothing but
1386
+ // print the English one. The sentence stays for a model over MCP, which has nothing to
1387
+ // formulate with.
1375
1388
  const callers = await deps.repository.flowCallers(actor, current.id);
1376
1389
  if (callers.visible.length || callers.hidden) {
1377
- throw new IntelError(409, "flow_in_use_by_flow", callersDetail(callers));
1390
+ throw new IntelError(409, "flow_in_use_by_flow", callersDetail(callers), namedOrCounted(callers));
1378
1391
  }
1379
1392
  // ⚠️ The one thing a flow has that a node does not: its runs can be the PARENT of runs
1380
1393
  // belonging to other flows. Deleting them would take the record of who started those runs
@@ -1518,9 +1531,18 @@ export function createFlows(deps) {
1518
1531
  compileFlow(version.graph);
1519
1532
  const wanted = [...new Set(treeLinkNodes(version.graph).map(resourceIdOf))];
1520
1533
  const reachable = new Set((await reachableNodes(actor, wanted)).map((reference) => reference.id));
1521
- for (const resourceId of wanted) {
1522
- if (!reachable.has(resourceId)) {
1523
- throw new IntelError(409, "flow_node_unavailable", `Node reference is unavailable: ${resourceId}`);
1534
+ // ⚠️ Asked over the deduplicated resource ids and REFUSED over the nodes (#551). The resource
1535
+ // id is what the ACL answers about, and it is also the one thing the author cannot look up:
1536
+ // it stands in no place the editor draws, so `Node reference is unavailable: n-01H…` named
1537
+ // the failure after the only participant nobody can find. The link node beside it carries the
1538
+ // label on the canvas, so the refusal names that instead.
1539
+ //
1540
+ // ⚠️ Naming the label reveals nothing the ACL is keeping: it is the author's own text in
1541
+ // their own graph, already on their screen — unlike the run-time refusal, which counts the
1542
+ // documents rather than naming them because there the READER may not see them (#17, #19).
1543
+ for (const node of treeLinkNodes(version.graph)) {
1544
+ if (!reachable.has(resourceIdOf(node))) {
1545
+ throw new IntelError(409, "flow_node_unavailable", `Node ${nodeName(node)} names a document you cannot reach`);
1524
1546
  }
1525
1547
  }
1526
1548
  for (const node of version.graph.nodes) {
package/dist/http/http.js CHANGED
@@ -8,7 +8,7 @@ import { Hono } from "hono";
8
8
  import { z } from "zod";
9
9
  import { authorizeBearer, bearer, permits, } from "../shared/gate-authorization/gate-authorization.js";
10
10
  import { IntelError } from "../shared/intel-error/intel-error.js";
11
- import { problemDetails as problem } from "../shared/problem-details/problem-details.js";
11
+ import { intelProblem, problemDetails as problem, } from "../shared/problem-details/problem-details.js";
12
12
  import { reportUnexpectedError } from "../shared/report-unexpected-error/report-unexpected-error.js";
13
13
  // A query string is a door to the outside like a body is, so what arrives through it is closed
14
14
  // rather than tolerated: `z.strictObject` refuses an unknown field, and this is that refusal for the
@@ -94,7 +94,7 @@ export function createHttp(deps) {
94
94
  }
95
95
  app.onError((error, context) => {
96
96
  if (error instanceof IntelError) {
97
- return context.json(problem(error.status, error.code, error.message), error.status);
97
+ return context.json(intelProblem(error), error.status);
98
98
  }
99
99
  if (error instanceof z.ZodError) {
100
100
  return context.json(problem(400, "invalid_request", "Request validation failed", z.prettifyError(error)), 400);
@@ -440,8 +440,28 @@ export function createHttp(deps) {
440
440
  const input = GetFlowInput.parse({ flowId: context.req.param("flowId") });
441
441
  return context.json(await deps.flows.get(asFlowActor(auth), input.flowId));
442
442
  });
443
- // A single flow as the same bundle shape a node export makes: one entry plus the manifest
444
- // (#136). `flows/read` like every other read of the graph.
443
+ /**
444
+ * A single flow as the same bundle shape a node export makes: one entry plus the manifest
445
+ * (#136). `flows/read` like every other read of the graph.
446
+ *
447
+ * ⚠️ Deliberately no `flow_export` tool beside it, and the reason is NOT the one `node_export`
448
+ * carries (#436). There the memory argument decides — a subtree's zip would have to fit in Worker
449
+ * memory twice — and a flow bundle is one entry plus the manifest, so that argument does not
450
+ * reach here.
451
+ *
452
+ * What decides here is that the manifest of ONE entry answers nothing MCP does not already
453
+ * answer. `node_export` is worth a tool because its manifest is the PLAN of a subtree: every
454
+ * readable entry with its path, kind and media type, which is the map for fetching each one. For
455
+ * a single flow that map has one row, whose id the caller already holds — and the substance, the
456
+ * graph, is `flow_version_get` and `flow_graph`, with the record itself in `flow_get`. A tool
457
+ * whose whole answer is two other tools' answers costs bytes in every `tools/list` and makes the
458
+ * other names less reliable as a guide, which `packages/api/CLAUDE.md` treats as the cost it is.
459
+ *
460
+ * ⚠️ This is not the case `.claude/rules/destructive.md` warns about — "a way only the surface
461
+ * has is a way an agent works around". Nothing about a flow is unreachable over MCP; only the zip
462
+ * CONTAINER is HTTP-only, exactly as it is for nodes. `mcp.e2e.ts` pins that this exception is
463
+ * named rather than merely absent.
464
+ */
445
465
  app.get("/flows/:flowId/export", async (context) => {
446
466
  const auth = requireCapability(context, "flows", "read");
447
467
  const input = GetFlowInput.parse({ flowId: context.req.param("flowId") });
@@ -3,7 +3,7 @@ import { createHttp } from "../http/http.js";
3
3
  import { handleMcp } from "../mcp/mcp.js";
4
4
  import { authorize, authorizeBearer, bearer, } from "../shared/gate-authorization/gate-authorization.js";
5
5
  import { IntelError } from "../shared/intel-error/intel-error.js";
6
- import { problemDetails } from "../shared/problem-details/problem-details.js";
6
+ import { intelProblem, problemDetails } from "../shared/problem-details/problem-details.js";
7
7
  import { reportUnexpectedError } from "../shared/report-unexpected-error/report-unexpected-error.js";
8
8
  export function createIntel(deps) {
9
9
  const baseUrl = deps.baseUrl.replace(/\/+$/, "");
@@ -16,7 +16,7 @@ export function createIntel(deps) {
16
16
  const app = new Hono();
17
17
  app.onError((error, context) => {
18
18
  if (error instanceof IntelError) {
19
- return context.json(problemDetails(error.status, error.code, error.message), error.status);
19
+ return context.json(intelProblem(error), error.status);
20
20
  }
21
21
  // An IntelError is an expected refusal and explains itself; the unknown exception must leave a
22
22
  // trace, or the 500 is undiagnosable — the worker answered, so the platform records no
package/dist/mcp/mcp.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { IdempotencyKey, IntelId } from "@anchrd/intel-contract";
2
2
  import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
3
3
  import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
4
- import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
5
- import { ListFlowGrantsInput, ListGrantsInput, RevokeFlowGrantInput, RevokeGrantInput, ShareFlowInput, ShareInput, } from "@anchrd/intel-contract/share";
4
+ import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
5
+ import { ListEffectiveAccessInput, ListFlowEffectiveAccessInput, ListFlowGrantsInput, ListGrantsInput, RevokeFlowGrantInput, RevokeGrantInput, ShareFlowInput, ShareInput, } from "@anchrd/intel-contract/share";
6
6
  import { AppendTableRowsInput, DefineTableInput, DeleteTableRowsInput, GetTableInput, RedefineTableInput, UpdateTableRowsInput, } from "@anchrd/intel-contract/table";
7
7
  import { ExecuteToolInput, TestToolInput } from "@anchrd/intel-contract/tool";
8
8
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -13,6 +13,44 @@ import { IntelError } from "../shared/intel-error/intel-error.js";
13
13
  function text(value) {
14
14
  return { content: [{ type: "text", text: JSON.stringify(value) }], isError: false };
15
15
  }
16
+ /**
17
+ * ⚠️ The one thing a tool error loses on its way out, and the whole reason this `catch` exists
18
+ * (#597).
19
+ *
20
+ * A thrown `IntelError` is turned into a tool error by the SDK's `createToolError`, which keeps
21
+ * `error instanceof Error ? error.message : String(error)` and nothing else — so `callers`, the
22
+ * structured half of a refusal that NAMES flows (#448, #593), never reaches a model over MCP.
23
+ *
24
+ * It arrives here as a SECOND content block. The first one is the sentence, byte for byte what the
25
+ * SDK would have sent, so a reader that only takes text loses nothing: the machine-readable half
26
+ * stands beside the prose rather than instead of it.
27
+ *
28
+ * ⚠️ It wraps the handlers whose service can refuse WITH data — `node_grant_revoke` and
29
+ * `flow_purge` today — and deliberately NOT all sixty. For every other refusal there is nothing to
30
+ * carry, and turning each of their errors into JSON would change every tool for the sake of two.
31
+ * What keeps that from becoming a place to forget is where the rule is written down: at the field
32
+ * itself (`IntelError.callers`), in `packages/api/CLAUDE.md`, and as two tests in `mcp.e2e.ts` —
33
+ * whoever attaches `callers` to a new refusal wraps its handler here.
34
+ */
35
+ async function refusing(run) {
36
+ try {
37
+ return text(await run());
38
+ }
39
+ catch (error) {
40
+ if (!(error instanceof IntelError) || !error.callers)
41
+ throw error;
42
+ return {
43
+ content: [
44
+ { type: "text", text: error.message },
45
+ {
46
+ type: "text",
47
+ text: JSON.stringify({ code: error.code, callers: error.callers }),
48
+ },
49
+ ],
50
+ isError: true,
51
+ };
52
+ }
53
+ }
16
54
  /**
17
55
  * ⚠️ One name per tool, and the second one is gone for good (#149).
18
56
  *
@@ -464,6 +502,34 @@ export async function handleMcp(request, deps) {
464
502
  openWorldHint: false,
465
503
  },
466
504
  }, async (input) => text(await deps.nodes.archive(actor, input)));
505
+ /**
506
+ * ⚠️ The look before the irreversible step, and it is on this surface because the step itself
507
+ * is (#586). `node_purge` has stood here since #457 for the reason #436 states — a way only the
508
+ * interface has is a way an agent goes around — but until now the COUNT did not, so an agent
509
+ * could delete for good without being able to see first what went with it. A person in the
510
+ * interface is shown both numbers and then confirms.
511
+ *
512
+ * That is exactly the state `.claude/rules/destructive.md` refuses in its own words: a cascade
513
+ * turns one confirmed click into an unknown number of deletions, and "the question can no
514
+ * longer name what disappears". Here the question could not be ASKED, which is the same gap one
515
+ * step earlier — and the half that was missing was the safe half.
516
+ *
517
+ * ⚠️ It answers about a tree, not about one row: `totalItems` counts the nodes AND the flows
518
+ * beneath it, the same two tables the purge itself walks. `inboundLinks` is the other kind of
519
+ * consequence — mentions elsewhere that will break and are deliberately not refused.
520
+ */
521
+ server.registerTool("node_purge_preview", {
522
+ title: "Preview a permanent deletion",
523
+ description: "Count what deleting one ARCHIVED node for good would take with it: every item in its subtree, documents and flows alike, and how many documents elsewhere link to it. It changes nothing. Ask before node_purge — afterwards there is no row left to count, and the links that break are not refused, only reported.",
524
+ inputSchema: PurgeNodePreviewInput,
525
+ annotations: {
526
+ title: "Preview a permanent deletion",
527
+ readOnlyHint: true,
528
+ destructiveHint: false,
529
+ idempotentHint: true,
530
+ openWorldHint: false,
531
+ },
532
+ }, async (input) => text(await deps.nodes.purgePreview(actor, input)));
467
533
  // ⚠️ It exists on this surface for the reason #436 states: a way only the interface has is a way
468
534
  // an agent goes around, by looking for the row in the database. What it may do is bounded by the
469
535
  // same rules the interface obeys — only an archived node, only an empty folder, never one a flow
@@ -498,6 +564,35 @@ export async function handleMcp(request, deps) {
498
564
  openWorldHint: false,
499
565
  },
500
566
  }, async (input) => text(await deps.nodes.listGrants(actor, input.resourceId)));
567
+ /**
568
+ * ⚠️ The neighbour above answers a NARROWER question, and that is why this one has to be here
569
+ * (#586). `node_grant_list` reads the rows on this node; this resolves the inheritance — every
570
+ * grant along the folders above plus the owners, who reach it without a grant row existing at
571
+ * all. On the everyday node, whose access comes from the folder it sits in, the first answer is
572
+ * empty and this one is not.
573
+ *
574
+ * Two queries with similar names are two different questions, and `.claude/rules/destructive.md`
575
+ * records what it costs to confuse them: in #457 a refusal asked the one that sounded right and
576
+ * never fired. Here the cost is the same shape and lands on the sharing path — an agent asked
577
+ * "who can see this" reaches for the only name it has, is answered `[]`, and reports that
578
+ * nobody can, about a document the whole organization opens. Silence would not be a missing
579
+ * answer here, it would be a confident wrong one.
580
+ *
581
+ * ⚠️ The list is for reading, never for revoking: only the entries whose `resourceId` is this
582
+ * node can be revoked here, the rest belong to the folder that granted them.
583
+ */
584
+ server.registerTool("node_effective_access_list", {
585
+ title: "List effective node access",
586
+ description: "List everybody who reaches one node once inheritance is resolved: the grants on it AND on every folder above it, plus the owners, who need no grant. node_grant_list answers the narrower question — the rows sitting on this node alone — and on a node that inherits its access that answer is empty while this one is not. An entry whose resourceId is another node was granted there and can only be revoked there.",
587
+ inputSchema: ListEffectiveAccessInput,
588
+ annotations: {
589
+ title: "List effective node access",
590
+ readOnlyHint: true,
591
+ destructiveHint: false,
592
+ idempotentHint: true,
593
+ openWorldHint: false,
594
+ },
595
+ }, async (input) => text(await deps.nodes.listEffectiveAccess(actor, input.resourceId)));
501
596
  server.registerTool("node_grant_create", {
502
597
  title: "Grant node access",
503
598
  description: "Grant inherited node access to a Gate user, verified email, or the organization.",
@@ -521,7 +616,7 @@ export async function handleMcp(request, deps) {
521
616
  idempotentHint: true,
522
617
  openWorldHint: false,
523
618
  },
524
- }, async (input) => text(await deps.nodes.revokeGrant(actor, input)));
619
+ }, async (input) => await refusing(() => deps.nodes.revokeGrant(actor, input)));
525
620
  }
526
621
  if (permits(deps.authorization, "flows", "read")) {
527
622
  server.registerTool("flow_list", {
@@ -681,6 +776,24 @@ export async function handleMcp(request, deps) {
681
776
  openWorldHint: false,
682
777
  },
683
778
  }, async (input) => text(await deps.flows.listGrants(flowActor, input.flowId)));
779
+ // ⚠️ The flow half of the node tool three groups up, and it is a SECOND tool rather than a
780
+ // widened one because it is a second question (#586): the answer is assembled from `flow_grants`
781
+ // and from the node grants of the folders above, and the owners come from the flow itself as
782
+ // well as from those folders. One tool over a `resourceId` that means either would have to guess
783
+ // which table to ask, and a caller passing the wrong kind would be answered "nobody" instead of
784
+ // being told.
785
+ server.registerTool("flow_effective_access_list", {
786
+ title: "List effective flow access",
787
+ description: "List everybody who reaches one flow once inheritance is resolved: the grants on the flow AND on the folders it is filed under, plus the owners, who need no grant. flow_grant_list answers the narrower question — the rows sitting on the flow itself — and for a flow whose access comes from its folder that answer is empty while this one is not. An entry whose resourceId is a folder was granted there and can only be revoked there.",
788
+ inputSchema: ListFlowEffectiveAccessInput,
789
+ annotations: {
790
+ title: "List effective flow access",
791
+ readOnlyHint: true,
792
+ destructiveHint: false,
793
+ idempotentHint: true,
794
+ openWorldHint: false,
795
+ },
796
+ }, async (input) => text(await deps.flows.listEffectiveAccess(flowActor, input.flowId)));
684
797
  server.registerTool("flow_grant_create", {
685
798
  title: "Grant flow access",
686
799
  description: "Grant access to one flow for a Gate user, verified email, or the organization. The grant reaches this flow alone — not the folder it is filed in, and not the flows it calls; the answer names what it does not cover.",
@@ -761,7 +874,7 @@ export async function handleMcp(request, deps) {
761
874
  idempotentHint: false,
762
875
  openWorldHint: false,
763
876
  },
764
- }, async (input) => text(await deps.flows.purge(flowActor, input)));
877
+ }, async (input) => await refusing(() => deps.flows.purge(flowActor, input)));
765
878
  server.registerTool("flow_version_create", {
766
879
  title: "Create flow version",
767
880
  description: "Append a validated immutable flow graph version with optimistic concurrency.",
@@ -1,4 +1,5 @@
1
1
  import { TableMediaType } from "@anchrd/intel-contract/table";
2
+ import { callersPhrase, namedOrCounted } from "../shared/callers/callers.js";
2
3
  import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
3
4
  import { requireFutureExpiry } from "../shared/grant-expiry/grant-expiry.js";
4
5
  import { IntelError } from "../shared/intel-error/intel-error.js";
@@ -28,13 +29,15 @@ function applicableVerbs(kind) {
28
29
  }
29
30
  // ⚠️ The refusal has to be actionable without becoming a directory of the tree. Whoever holds
30
31
  // `share` on one folder must not learn the titles of flows they may not see, so the ones they may
31
- // see are named and the rest are only counted (ADR-0004 §3, and #17's review).
32
+ // see are named and the rest are only counted (ADR-0004 §3, and #17's review) — which is the whole
33
+ // of what `callersPhrase` does, in the one place both refusals read it from.
34
+ //
35
+ // The sentence stays for whoever has nothing to formulate with — a model over MCP reads it as it
36
+ // stands — and `namedOrCounted` carries the same decision as data, so a screen can say it in its
37
+ // own language (#448). Both come out of one place: the split between named and counted is an
38
+ // authorization answer, and a surface that recomputed it would be deciding what it may not.
32
39
  function callersDetail(callers) {
33
- const named = callers.visible.map((title) => `“${title}”`).join(", ");
34
- const rest = callers.hidden === 0
35
- ? ""
36
- : `${named ? " and " : ""}${callers.hidden} more flow${callers.hidden === 1 ? "" : "s"} you cannot see`;
37
- return `Flows outside this folder call into it: ${named}${rest}. Change or unpublish them before narrowing the folder.`;
40
+ return `Flows outside this folder call into it: ${callersPhrase(callers)}. Change or unpublish them before narrowing the folder.`;
38
41
  }
39
42
  // The grantee as the ACL sees them, and as nothing else: an identity with no capability of its own,
40
43
  // never `isAdmin`. It answers for Intel's resource ACLs only — whether Gate hands this person
@@ -1085,7 +1088,7 @@ export function createNodes(deps) {
1085
1088
  !(await deps.repository.organizationExecuteReaches(input.resourceId, input.grantId))) {
1086
1089
  const callers = await deps.externalFlowCallers(actor, input.resourceId);
1087
1090
  if (callers.visible.length || callers.hidden) {
1088
- throw new IntelError(409, "folder_execute_in_use", callersDetail(callers));
1091
+ throw new IntelError(409, "folder_execute_in_use", callersDetail(callers), namedOrCounted(callers));
1089
1092
  }
1090
1093
  }
1091
1094
  return {
@@ -0,0 +1,28 @@
1
+ import type { NamedOrCounted } from "@anchrd/intel-contract";
2
+ type Callers = {
3
+ visible: string[];
4
+ hidden: number;
5
+ };
6
+ /**
7
+ * ⚠️ Named where the reader may see it, counted where they may not (ADR-0004 §3). A refusal has to
8
+ * be actionable without becoming a way of reading the tree: whoever holds one grant on one folder
9
+ * must not learn the titles of flows they are refused directly.
10
+ *
11
+ * This is the middle of both refusals that ask the question — narrowing a folder (`nodes.ts`) and
12
+ * deleting a flow for good (`flows.ts`). ⚠️ **The two SENTENCES around it stay two**, because they
13
+ * say different things to different people: one is about a folder that is about to lose a grant,
14
+ * the other about a flow that is about to be gone. Folding them into one parameterised sentence
15
+ * would buy a shared string and cost the reason each one gives.
16
+ */
17
+ export declare function callersPhrase(callers: Callers): string;
18
+ /**
19
+ * The same two facts the sentence is built from, in the shape a grant's own warning already uses
20
+ * (`UnreadableNodes`, #448). One question — what may you see, and of how much — deserves one answer
21
+ * shape on every surface, and a screen that has to write the sentence itself needs the facts and
22
+ * not the sentence.
23
+ *
24
+ * ⚠️ It reads the SAME argument the phrase above reads. A surface that recomputed the split would
25
+ * be deciding what it may not, and a second decision here would be the one that drifts.
26
+ */
27
+ export declare function namedOrCounted(callers: Callers): NamedOrCounted;
28
+ export {};
@@ -0,0 +1,30 @@
1
+ /**
2
+ * ⚠️ Named where the reader may see it, counted where they may not (ADR-0004 §3). A refusal has to
3
+ * be actionable without becoming a way of reading the tree: whoever holds one grant on one folder
4
+ * must not learn the titles of flows they are refused directly.
5
+ *
6
+ * This is the middle of both refusals that ask the question — narrowing a folder (`nodes.ts`) and
7
+ * deleting a flow for good (`flows.ts`). ⚠️ **The two SENTENCES around it stay two**, because they
8
+ * say different things to different people: one is about a folder that is about to lose a grant,
9
+ * the other about a flow that is about to be gone. Folding them into one parameterised sentence
10
+ * would buy a shared string and cost the reason each one gives.
11
+ */
12
+ export function callersPhrase(callers) {
13
+ const named = callers.visible.map((title) => `“${title}”`).join(", ");
14
+ const rest = callers.hidden === 0
15
+ ? ""
16
+ : `${named ? " and " : ""}${callers.hidden} more flow${callers.hidden === 1 ? "" : "s"} you cannot see`;
17
+ return `${named}${rest}`;
18
+ }
19
+ /**
20
+ * The same two facts the sentence is built from, in the shape a grant's own warning already uses
21
+ * (`UnreadableNodes`, #448). One question — what may you see, and of how much — deserves one answer
22
+ * shape on every surface, and a screen that has to write the sentence itself needs the facts and
23
+ * not the sentence.
24
+ *
25
+ * ⚠️ It reads the SAME argument the phrase above reads. A surface that recomputed the split would
26
+ * be deciding what it may not, and a second decision here would be the one that drifts.
27
+ */
28
+ export function namedOrCounted(callers) {
29
+ return { titles: callers.visible, hidden: callers.hidden };
30
+ }
@@ -1,5 +1,22 @@
1
+ import type { NamedOrCounted } from "@anchrd/intel-contract";
1
2
  export declare class IntelError extends Error {
2
3
  readonly status: number;
3
4
  readonly code: string;
4
- constructor(status: number, code: string, message: string);
5
+ readonly callers?: NamedOrCounted | undefined;
6
+ /**
7
+ * ⚠️ `callers` is what a refusal that NAMES flows carries beside its sentence — today
8
+ * `folder_execute_in_use` (#448) and `flow_in_use_by_flow` (#593). It rides on the error rather
9
+ * than on the route because the split it belongs to is an application-layer one: who may be named
10
+ * and who may only be counted is an authorization answer, and it must be the same over HTTP, over
11
+ * MCP and in the browser. A route that assembled it would be a second place for that rule to live.
12
+ *
13
+ * `message` still carries the finished English sentence — a reader that only takes text must lose
14
+ * nothing — and the field is what lets a screen say it in its own language instead.
15
+ *
16
+ * ⚠️ **Setting this field is only half of reaching MCP.** The SDK formats a thrown error through
17
+ * `createToolError`, which keeps `message` and drops everything else, so a tool whose handler is
18
+ * not wrapped in `refusing()` (`mcp/mcp.ts`) answers prose and nothing more (#597). Whoever
19
+ * attaches `callers` to a new refusal wraps that tool's handler in the same change.
20
+ */
21
+ constructor(status: number, code: string, message: string, callers?: NamedOrCounted | undefined);
5
22
  }
@@ -1,10 +1,27 @@
1
1
  export class IntelError extends Error {
2
2
  status;
3
3
  code;
4
- constructor(status, code, message) {
4
+ callers;
5
+ /**
6
+ * ⚠️ `callers` is what a refusal that NAMES flows carries beside its sentence — today
7
+ * `folder_execute_in_use` (#448) and `flow_in_use_by_flow` (#593). It rides on the error rather
8
+ * than on the route because the split it belongs to is an application-layer one: who may be named
9
+ * and who may only be counted is an authorization answer, and it must be the same over HTTP, over
10
+ * MCP and in the browser. A route that assembled it would be a second place for that rule to live.
11
+ *
12
+ * `message` still carries the finished English sentence — a reader that only takes text must lose
13
+ * nothing — and the field is what lets a screen say it in its own language instead.
14
+ *
15
+ * ⚠️ **Setting this field is only half of reaching MCP.** The SDK formats a thrown error through
16
+ * `createToolError`, which keeps `message` and drops everything else, so a tool whose handler is
17
+ * not wrapped in `refusing()` (`mcp/mcp.ts`) answers prose and nothing more (#597). Whoever
18
+ * attaches `callers` to a new refusal wraps that tool's handler in the same change.
19
+ */
20
+ constructor(status, code, message, callers) {
5
21
  super(message);
6
22
  this.status = status;
7
23
  this.code = code;
24
+ this.callers = callers;
8
25
  this.name = "IntelError";
9
26
  }
10
27
  }
@@ -1,3 +1,4 @@
1
+ import type { IntelError } from "../intel-error/intel-error.js";
1
2
  export declare function problemDetails(status: number, code: string, title: string, detail?: string): {
2
3
  type: string;
3
4
  status: number;
@@ -5,4 +6,25 @@ export declare function problemDetails(status: number, code: string, title: stri
5
6
  detail?: string | undefined;
6
7
  instance?: string | undefined;
7
8
  code?: string | undefined;
9
+ callers?: {
10
+ titles: string[];
11
+ hidden: number;
12
+ } | undefined;
13
+ };
14
+ /**
15
+ * One refusal, one document — written here rather than at each `onError`, because two apps turn an
16
+ * `IntelError` into a body and an extension member added at one of them would silently be missing
17
+ * from the other (#448). Whatever the application layer decided to say travels with it.
18
+ */
19
+ export declare function intelProblem(error: IntelError): {
20
+ type: string;
21
+ status: number;
22
+ title: string;
23
+ detail?: string | undefined;
24
+ instance?: string | undefined;
25
+ code?: string | undefined;
26
+ callers?: {
27
+ titles: string[];
28
+ hidden: number;
29
+ } | undefined;
8
30
  };
@@ -2,3 +2,17 @@ import { ProblemDetails } from "@anchrd/intel-contract";
2
2
  export function problemDetails(status, code, title, detail) {
3
3
  return ProblemDetails.parse({ type: "about:blank", status, code, title, detail });
4
4
  }
5
+ /**
6
+ * One refusal, one document — written here rather than at each `onError`, because two apps turn an
7
+ * `IntelError` into a body and an extension member added at one of them would silently be missing
8
+ * from the other (#448). Whatever the application layer decided to say travels with it.
9
+ */
10
+ export function intelProblem(error) {
11
+ return ProblemDetails.parse({
12
+ type: "about:blank",
13
+ status: error.status,
14
+ code: error.code,
15
+ title: error.message,
16
+ callers: error.callers,
17
+ });
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -42,8 +42,8 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "dependencies": {
45
- "@anchrd/gate-sdk": "^0.19.0",
46
- "@anchrd/intel-contract": "^0.21.0",
45
+ "@anchrd/gate-sdk": "^0.20.0",
46
+ "@anchrd/intel-contract": "^0.22.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",