@anchrd/intel-api 0.24.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.
package/dist/cli/cli.js CHANGED
@@ -1,38 +1,81 @@
1
1
  import { createBuild } from "../build/build.js";
2
2
  import { createPrepare } from "../prepare/prepare.js";
3
3
  const interfaces = [
4
- { handle: "intel", functions: ["use", "admin"] },
4
+ // ⚠️ No `use`. Nothing asked it (#475): every door in Intel asks a concrete capability —
5
+ // `nodes:read`, `flows:run`, `mcp:connect` — and a coarse "may use Intel at all" beside them is a
6
+ // permission an operator sets and nothing consults. Worse than useless: somebody granted only
7
+ // `intel:use` gets the empty screen the README warns about and reads it as a defect.
8
+ // `admin` stays and is the opposite case — it is the one way into a tree that carries no grant
9
+ // yet (ADR-0004 §6), read in ten places.
10
+ { handle: "intel", functions: ["admin"] },
5
11
  // ⚠️ `nodes`, not `knowledge` — and the rename cost something, which is why it needed its own
6
12
  // ticket (#152) five months after #125 renamed everything else. This handle is only half Intel's:
7
- // `bootstrap` DECLARES it in Gate and deliberately changes no grant, so to Gate a renamed handle
8
- // is a NEW one. Every grant on the old name keeps pointing at the old name, and everybody loses
9
- // access at once, silently, until an operator hands the permissions out again.
13
+ // `bootstrap` DECLARES it in Gate and touches no grant on an interface it does not name, so to
14
+ // Gate a renamed handle is a NEW one. Every grant on the old name keeps pointing at the old name,
15
+ // and everybody loses access at once, silently, until an operator hands the permissions out again.
10
16
  //
11
17
  // It was done in the one window where that is free: the installation on anchrd.sh was being
12
18
  // rebuilt anyway (#385, anchrd/core#93) and there is no other. Doing it later would mean doing it
13
19
  // to somebody.
14
20
  //
15
- // ⚠️ The old `knowledge` interface stays declared in Gate — bootstrap declares, it never revokes.
16
- // It is a harmless leftover on installations that have it, and nothing in Intel asks about it any
17
- // more; removing it is an act in Gate, by hand, when somebody is sure nothing else uses it.
21
+ // ⚠️ The old `knowledge` interface stays declared in Gate — bootstrap declares only the handles in
22
+ // this list and never revokes a whole INTERFACE. It is a harmless leftover on installations that
23
+ // have it, and nothing in Intel asks about it any more; removing it is an act in Gate, by hand,
24
+ // when somebody is sure nothing else uses it.
18
25
  { handle: "nodes", functions: ["read", "create", "write", "share"] },
19
26
  // ⚠️ No `approve`. The approval node is gone (#73), and this list is what Intel declares to Gate:
20
- // a function nobody asks about is a permission an operator has to decide on for no reason. An
21
- // installation that already granted it keeps a harmless leftover — bootstrap declares, it does
22
- // not revoke.
27
+ // a function nobody asks about is a permission an operator has to decide on for no reason.
28
+ //
29
+ // ⚠️ Dropping a FUNCTION from a still-declared interface is not the harmless leftover the two
30
+ // blocks around this one describe (#474). `gate.set` is `PUT /api/v1/service/interfaces/:handle`,
31
+ // an upsert that REPLACES the function list, and in the same batch Gate runs `obsoleteGrants` —
32
+ // `DELETE FROM grants WHERE interface_id = ? AND function NOT IN (…)`. Gate holds that down with
33
+ // a test of its own (`service-interfaces.int.ts`, "scoped set/remove per App-Key und entfernt
34
+ // Grants gestrichener Funktionen"), and it is right to: catalog and grants are one change against
35
+ // Gate's own reasoning above `obsoleteGrants`. So an installation that granted `approve` does
36
+ // NOT keep it — the next bootstrap deletes every role assignment on it. That is why bootstrap
37
+ // reads the current list before it writes and names what it takes away.
23
38
  {
24
39
  handle: "flows",
25
40
  functions: ["read", "create", "write", "publish", "run", "share"],
26
41
  },
27
- { handle: "tools", functions: ["read", "test", "execute", "admin"] },
28
- // ⚠️ `agents:run` is deliberately NOT here any more (#390). `bootstrap` declares, it never
29
- // revokes, so an installation that has the interface keeps it as a harmless leftover but a new
30
- // one is not asked to decide about a permission nothing reads.
42
+ // ⚠️ No `admin`. Nothing asked it either (#475), and here the reason is architectural rather than
43
+ // an oversight: the Cloudflare MCP Portal owns which servers exist and who may reach each one, and
44
+ // Intel stores no tool permissions at all the catalog is a live `tools/list` with the asking
45
+ // person's token. There is no administration of tools inside Intel for the function to guard, so
46
+ // it cannot grow a reader later without that boundary moving first.
47
+ { handle: "tools", functions: ["read", "test", "execute"] },
48
+ // ⚠️ `agents:run` is deliberately NOT here any more (#390). The whole `agents` handle left the
49
+ // list, and bootstrap writes only the handles it names, so an installation that has that
50
+ // interface keeps it and its grants as a harmless leftover — but a new one is not asked to decide
51
+ // about a permission nothing reads. ⚠️ This holds because a whole INTERFACE went; a single
52
+ // function taken out of a handle that is still listed here is deleted along with its grants, see
53
+ // the block above (#474).
31
54
  // `mcp:connect` means the same thing at every MCP service of this installation. A separate name
32
55
  // for the same thing forces the operator to check, service by service, which permission carries
33
56
  // portal access.
34
57
  { handle: "mcp", functions: ["connect"] },
35
58
  ];
59
+ // What Intel used to declare and no longer does, keyed by handle for a whole interface and by
60
+ // `handle:function` for a single function.
61
+ //
62
+ // ⚠️ It exists because "surplus: approve" is not actionable on its own (#466). An operator who reads
63
+ // only the name has to decide whether somebody meant it, and the cheapest way out of that decision
64
+ // is to leave it standing — which is how `approve` outlived #73 by months. The reason is the half
65
+ // that turns the note into a decision instead of a puzzle, so a new retirement belongs in here in
66
+ // the same pull request that takes its entry out of `interfaces` above.
67
+ const retired = {
68
+ knowledge: "the whole interface became `nodes` (#152, after #125).",
69
+ agents: "the whole interface went when Agents left the surface (#390, #388).",
70
+ "flows:approve": "the approval node is gone (#73).",
71
+ "intel:use": "no door ever asked it; every one of them asks a concrete capability (#475).",
72
+ "tools:admin": "the portal owns tool administration, so Intel never had anything to guard (#475).",
73
+ };
74
+ // An unlisted surplus gets a sentence too. "no reason recorded" is an answer an operator can act on;
75
+ // an empty dash reads as an omission and sends them looking for the missing half.
76
+ function reason(key) {
77
+ return retired[key] ?? "no reason is recorded here, so read the history before removing it.";
78
+ }
36
79
  const usage = `Usage: intel <prepare|bootstrap|build|doctor|reindex>
37
80
  intel prepare Copy versioned D1 migrations to .intel/migrations
38
81
  intel bootstrap Idempotently declare Intel interfaces in Gate
@@ -67,13 +110,41 @@ export function createCli(deps) {
67
110
  return fail("GATE_URL and GATE_SERVICE_KEY must be set.");
68
111
  }
69
112
  const gate = deps.createGateInterfaces(gateUrl, serviceKey);
70
- for (const entry of interfaces)
113
+ // ⚠️ Read before writing, and refuse if the read fails. The current list is the only place the
114
+ // functions Gate is about to lose are named; `set` replaces it, and afterwards nothing can say
115
+ // which grants went with them (#474). A run that writes anyway takes permissions away and has
116
+ // nothing left to report — the old "No grants were changed." in exactly the shape that made it
117
+ // wrong. Bootstrap is idempotent, so refusing costs a second run.
118
+ let current;
119
+ try {
120
+ current = await within(gate.list());
121
+ }
122
+ catch {
123
+ return fail("Gate interfaces could not be read, so bootstrap cannot say which grants it would delete.");
124
+ }
125
+ let removed = 0;
126
+ for (const entry of interfaces) {
127
+ const declared = entry.functions;
128
+ const dropped = (current.find((row) => row.handle === entry.handle)?.functions ?? []).filter((fn) => !declared.includes(fn));
71
129
  await gate.set(entry.handle, [...entry.functions]);
72
- deps.log(`Declared ${interfaces.length} Intel interfaces in Gate. No grants were changed.`);
130
+ // Logged per interface, right after its own write returned, so a run that fails halfway still
131
+ // named every deletion it actually caused.
132
+ if (dropped.length) {
133
+ removed += dropped.length;
134
+ const names = dropped.map((fn) => `${entry.handle}:${fn}`).join(", ");
135
+ deps.log(`Removed ${names} from Gate; every grant on ${dropped.length === 1 ? "it" : "them"} was deleted with it.`);
136
+ }
137
+ }
138
+ deps.log(`Declared ${interfaces.length} Intel interfaces in Gate.`);
139
+ if (!removed)
140
+ deps.log("No declared function was dropped, so Gate deleted no grant.");
73
141
  return 0;
74
142
  }
75
143
  async function doctor() {
76
144
  const failures = [];
145
+ // A surplus hinders nothing, so it is never a `FAIL`: a doctor one knows red is a doctor nobody
146
+ // reads, and then the next real failure goes with it (#466).
147
+ const notes = [];
77
148
  const gateUrl = envValue("GATE_URL");
78
149
  const intelUrl = envValue("INTEL_URL");
79
150
  const serviceKey = envValue("GATE_SERVICE_KEY") ?? envValue("GATE_KEY");
@@ -106,17 +177,44 @@ export function createCli(deps) {
106
177
  if (gateUrl && serviceKey) {
107
178
  try {
108
179
  const current = await within(deps.createGateInterfaces(gateUrl, serviceKey).list());
180
+ let surplusFunction = false;
109
181
  for (const expected of interfaces) {
110
182
  const actual = current.find((entry) => entry.handle === expected.handle);
111
183
  const missing = expected.functions.filter((fn) => !actual?.functions.includes(fn));
112
184
  if (missing.length)
113
185
  failures.push(`Gate interface ${expected.handle} misses ${missing}`);
186
+ const declared = expected.functions;
187
+ for (const fn of actual?.functions ?? []) {
188
+ if (declared.includes(fn))
189
+ continue;
190
+ surplusFunction = true;
191
+ notes.push(`Gate carries ${expected.handle}:${fn}, which Intel does not declare — ${reason(`${expected.handle}:${fn}`)}`);
192
+ }
193
+ }
194
+ // ⚠️ The other direction, and it is the half a loop over `interfaces` can never reach: a
195
+ // handle Intel stopped declaring appears in no expectation, so it is never looked up. The
196
+ // comparison runs both ways or it does not run (#466).
197
+ const declaredHandles = interfaces.map((entry) => entry.handle);
198
+ const surplusHandles = current.filter((row) => !declaredHandles.includes(row.handle));
199
+ for (const row of surplusHandles) {
200
+ notes.push(`Gate carries the interface ${row.handle} (${row.functions.join(", ")}), which Intel does not declare — ${reason(row.handle)}`);
201
+ }
202
+ // What to do about it, once per kind — because the two kinds need opposite acts. A surplus
203
+ // FUNCTION on a still-declared handle disappears by itself on the next `bootstrap`, grants
204
+ // and all (#474); a surplus HANDLE is never written and therefore never goes away on its own.
205
+ if (surplusFunction) {
206
+ notes.push("The next `intel bootstrap` removes a surplus function and every grant on it (#474). Take the grant away in Gate first if anybody should keep it.");
207
+ }
208
+ if (surplusHandles.length) {
209
+ notes.push("`bootstrap` never touches an interface Intel does not declare, so a surplus one stays until somebody removes it in Gate by hand — which takes its grants with it.");
114
210
  }
115
211
  }
116
212
  catch {
117
213
  failures.push("Gate interface check failed");
118
214
  }
119
215
  }
216
+ for (const note of notes)
217
+ deps.log(`NOTE ${note}`);
120
218
  if (failures.length) {
121
219
  for (const failure of failures)
122
220
  deps.log(`FAIL ${failure}`);
@@ -1,4 +1,6 @@
1
1
  import { flowNodeLayer } from "@anchrd/intel-contract/flow";
2
+ import { callersPhrase, namedOrCounted } from "../shared/callers/callers.js";
3
+ import { requireFutureExpiry } from "../shared/grant-expiry/grant-expiry.js";
2
4
  import { IntelError } from "../shared/intel-error/intel-error.js";
3
5
  import { plainTitle } from "../shared/plain-title/plain-title.js";
4
6
  function invalid(detail) {
@@ -110,11 +112,32 @@ function toolStepDetail(missing) {
110
112
  ? `You do not reach this tool server in the portal: ${missing.join(", ")}`
111
113
  : `You do not reach these tool servers in the portal: ${missing.join(", ")}`;
112
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
+ }
113
129
  export function compileFlow(graph) {
114
130
  const nodes = new Map();
131
+ // ⚠️ It stands ABOVE the loop that fills the map on purpose, so the duplicate-id refusal reaches
132
+ // it too — that one names the node the map already holds, which is the FIRST of the two. An id
133
+ // nothing knows falls back to the bare id, which is what the dangling-edge case wants anyway.
134
+ const named = (id) => {
135
+ const node = nodes.get(id);
136
+ return node ? nodeName(node) : id;
137
+ };
115
138
  for (const node of graph.nodes) {
116
139
  if (nodes.has(node.id))
117
- invalid(`Duplicate node ID: ${node.id}`);
140
+ invalid(`Duplicate node ID: ${named(node.id)}`);
118
141
  nodes.set(node.id, node);
119
142
  }
120
143
  // ⚠️ Every arity rule below counts flow edges only. A context edge says "this belongs to that
@@ -137,7 +160,7 @@ export function compileFlow(graph) {
137
160
  // One holder per attached node: two steps claiming the same material would make "what does
138
161
  // this step work with" answerable two ways, and the second answer would never be shown.
139
162
  if (attachedTo.has(edge.target))
140
- invalid(`Node ${edge.target} is already attached to ${attachedTo.get(edge.target)}`);
163
+ invalid(`Node ${named(edge.target)} is already attached to ${named(attachedTo.get(edge.target) ?? "")}`);
141
164
  attachedTo.set(edge.target, edge.source);
142
165
  attachments.set(edge.source, [...(attachments.get(edge.source) ?? []), edge]);
143
166
  continue;
@@ -168,44 +191,44 @@ export function compileFlow(graph) {
168
191
  // Before this, a link could stand in the chain — which is how a start with an attachment once
169
192
  // began its run at the attachment (#37).
170
193
  if (layer === "link" && holderId === undefined) {
171
- invalid(`Node ${node.id} is a link and must be attached to a step`);
194
+ invalid(`Node ${named(node.id)} is a link and must be attached to a step`);
172
195
  }
173
196
  if (layer !== "link" && holderId !== undefined) {
174
- invalid(`Node ${node.id} is not a link and cannot be attached to another node`);
197
+ invalid(`Node ${named(node.id)} is not a link and cannot be attached to another node`);
175
198
  }
176
199
  if (holderId !== undefined) {
177
200
  if (parents !== 0 || children.length !== 0) {
178
- invalid(`Node ${node.id} is attached as context and cannot also be a step`);
201
+ invalid(`Node ${named(node.id)} is attached as context and cannot also be a step`);
179
202
  }
180
203
  const holder = nodes.get(holderId);
181
204
  // Only a step holds material. A marker carries nothing at all, and a `subflow` is a call: the
182
205
  // flow it names brings its own links, and lending it one from here would be steering another
183
206
  // flow from outside.
184
207
  if (holder && (flowNodeLayer[holder.kind] !== "step" || holder.kind === "subflow")) {
185
- invalid(`Node ${node.id} can only be attached to an instruction or a condition`);
208
+ invalid(`Node ${named(node.id)} can only be attached to an instruction or a condition`);
186
209
  }
187
210
  continue;
188
211
  }
189
212
  if (node.kind === "trigger" && parents !== 0)
190
213
  invalid("The trigger cannot have an incoming edge");
191
214
  if (node.kind !== "trigger" && parents !== 1) {
192
- invalid(`Node ${node.id} requires exactly one incoming edge`);
215
+ invalid(`Node ${named(node.id)} requires exactly one incoming edge`);
193
216
  }
194
217
  if (node.kind === "output" && children.length !== 0)
195
- invalid(`Output ${node.id} must be terminal`);
218
+ invalid(`Output ${named(node.id)} must be terminal`);
196
219
  if (node.kind === "condition") {
197
220
  if (children.length < 2)
198
- invalid(`Node ${node.id} requires at least two branches`);
221
+ invalid(`Node ${named(node.id)} requires at least two branches`);
199
222
  const handles = new Set(children.map((edge) => edge.sourceHandle));
200
223
  if (handles.has(null) || handles.size !== children.length) {
201
- invalid(`Node ${node.id} requires unique branch handles`);
224
+ invalid(`Node ${named(node.id)} requires unique branch handles`);
202
225
  }
203
226
  }
204
227
  else if (node.kind !== "output") {
205
228
  if (children.length !== 1)
206
- invalid(`Node ${node.id} requires exactly one outgoing edge`);
229
+ invalid(`Node ${named(node.id)} requires exactly one outgoing edge`);
207
230
  if (children[0]?.sourceHandle !== null)
208
- invalid(`Node ${node.id} cannot define a branch handle`);
231
+ invalid(`Node ${named(node.id)} cannot define a branch handle`);
209
232
  }
210
233
  }
211
234
  const visited = new Set();
@@ -300,14 +323,14 @@ function decodeCursor(cursor) {
300
323
  return { createdAt, id };
301
324
  }
302
325
  // ⚠️ Named where the reader may see it, counted where they may not — the same rule the folder's
303
- // refusal follows (`callersDetail` in nodes.ts): a refusal must not become a way of learning that a
304
- // 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.
305
332
  function callersDetail(callers) {
306
- const named = callers.visible.map((title) => `“${title}”`).join(", ");
307
- const rest = callers.hidden === 0
308
- ? ""
309
- : `${named ? " and " : ""}${callers.hidden} more flow${callers.hidden === 1 ? "" : "s"} you cannot see`;
310
- 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.`;
311
334
  }
312
335
  export function createFlows(deps) {
313
336
  async function requireFlow(actor, flowId) {
@@ -1187,6 +1210,7 @@ export function createFlows(deps) {
1187
1210
  flowId: flow.id,
1188
1211
  versionId: null,
1189
1212
  nodes: [],
1213
+ invalidNodes: [],
1190
1214
  hiddenNodes: 0,
1191
1215
  servers: [],
1192
1216
  };
@@ -1194,16 +1218,31 @@ export function createFlows(deps) {
1194
1218
  const version = await requireVersion(versionId, flow.id);
1195
1219
  const referenced = graphReferences(version.graph);
1196
1220
  // ⚠️ The same lookup a tree link passes through, not a second rule written for a list.
1197
- // What it hands back is named; the difference between what was asked for and what came back
1198
- // is a number, because a title is exactly what someone without access may not learn (#17,
1199
- // #19). Tool names are not filtered: they come from a graph this actor may already read, and
1200
- // whether the portal offers them is a question only their own token can answer.
1221
+ // What it hands back is named; a title is exactly what someone without access may not learn
1222
+ // (#17, #19). Tool names are not filtered: they come from a graph this actor may already
1223
+ // read, and whether the portal offers them is a question only their own token can answer.
1201
1224
  const reachable = await reachableNodes(actor, referenced.nodes);
1225
+ const named = new Set(reachable.map((reference) => reference.id));
1226
+ const unreachable = referenced.nodes.filter((resourceId) => !named.has(resourceId));
1227
+ // ⚠️ What is left over used to be ONE number, and the panel read it out as "you cannot see
1228
+ // it" — a sentence about a permission, in front of a link that names nothing at all (#509).
1229
+ // The two suggest opposite actions and only one of them helps: with a permission one asks for
1230
+ // access, with a dead reference the step has to be replaced before the flow can run.
1231
+ //
1232
+ // ⚠️ The second question is asked ONLY about what the visibility door already refused, and it
1233
+ // is asked without an actor because its answer does not depend on one. That is the narrowest
1234
+ // shape this distinction can have — the alternative, a lookup that is kinder than the door it
1235
+ // describes, is what #17 and #19 were sent back for. It is still a disclosure and it is a
1236
+ // deliberate one (#492): for an id the caller wrote into this graph themselves, they learn
1237
+ // whether the tree still holds a row for it.
1238
+ const alive = new Set(await deps.repository.existingNodes(unreachable));
1239
+ const invalidNodes = unreachable.filter((resourceId) => !alive.has(resourceId));
1202
1240
  return {
1203
1241
  flowId: flow.id,
1204
1242
  versionId: version.id,
1205
1243
  nodes: reachable,
1206
- hiddenNodes: referenced.nodes.length - reachable.length,
1244
+ invalidNodes,
1245
+ hiddenNodes: unreachable.length - invalidNodes.length,
1207
1246
  servers: referenced.servers,
1208
1247
  };
1209
1248
  },
@@ -1233,6 +1272,11 @@ export function createFlows(deps) {
1233
1272
  if (replayed)
1234
1273
  return await withShareWarnings(actor, flow, replayed, replayed.principal);
1235
1274
  }
1275
+ // ⚠️ The same rule as on a node grant, in the same place and for the same two reasons (#442):
1276
+ // in front of `setFlowGrant` so nothing that could never work is written, behind the replay so
1277
+ // a retry with the same key still answers with the grant its first attempt made. The long
1278
+ // form of the argument stands at the node grant in `nodes.ts`.
1279
+ requireFutureExpiry(input.expiresAt, deps.now());
1236
1280
  const timestamp = deps.now().toISOString();
1237
1281
  const grant = await deps.repository.setFlowGrant({
1238
1282
  grant: {
@@ -1337,9 +1381,13 @@ export function createFlows(deps) {
1337
1381
  throw new IntelError(409, "flow_not_archived", "Only an archived flow can be deleted for good. Archive it first.");
1338
1382
  }
1339
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.
1340
1388
  const callers = await deps.repository.flowCallers(actor, current.id);
1341
1389
  if (callers.visible.length || callers.hidden) {
1342
- 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));
1343
1391
  }
1344
1392
  // ⚠️ The one thing a flow has that a node does not: its runs can be the PARENT of runs
1345
1393
  // belonging to other flows. Deleting them would take the record of who started those runs
@@ -1483,9 +1531,18 @@ export function createFlows(deps) {
1483
1531
  compileFlow(version.graph);
1484
1532
  const wanted = [...new Set(treeLinkNodes(version.graph).map(resourceIdOf))];
1485
1533
  const reachable = new Set((await reachableNodes(actor, wanted)).map((reference) => reference.id));
1486
- for (const resourceId of wanted) {
1487
- if (!reachable.has(resourceId)) {
1488
- 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`);
1489
1546
  }
1490
1547
  }
1491
1548
  for (const node of version.graph.nodes) {
@@ -82,6 +82,21 @@ export interface FlowRepository {
82
82
  hidden: number;
83
83
  }>;
84
84
  nodeReferences(actor: FlowPrincipal, folderId: string): Promise<string[]>;
85
+ /**
86
+ * Which of these ids still have a row in the tree at all — archived or not, for anybody (#509).
87
+ *
88
+ * ⚠️ It takes NO actor, and that is the honest shape rather than an oversight: the answer does
89
+ * not depend on who is asking, so an `actor` parameter would suggest a narrowing that is not
90
+ * there. Its one caller has already asked `visibleNodes` and only reaches this for the ids that
91
+ * came back empty, so what it adds is a single bit about ids the caller sent us out of a graph
92
+ * they may already read.
93
+ *
94
+ * ⚠️ An ARCHIVED node counts as existing. Archiving is reversible — the row is still there and a
95
+ * restore brings the reference back — so calling it invalid would tell an author to replace a
96
+ * step that is about to work again. Only a purge takes the row (`DELETE FROM nodes`, #457), and
97
+ * only that is what "there is nothing here any more" means.
98
+ */
99
+ existingNodes(nodeIds: string[]): Promise<string[]>;
85
100
  publishedCallees(flowId: string): Promise<{
86
101
  title: string;
87
102
  calleeIds: string[];
@@ -228,6 +243,12 @@ export interface FlowDeps {
228
243
  * requirements list names it; and every tree link of every run is authorized by it, nested
229
244
  * calls included (ADR-0004 §4). A second lookup beside it is how a drawing or a message ends up
230
245
  * kinder than the door it describes — which is what #17 and #19 were sent back for.
246
+ *
247
+ * ⚠️ `existingNodes` on the repository is the ONE thing asked beside it, and it is the exception
248
+ * that shows the rule rather than a hole in it: it runs only on ids this door already refused, it
249
+ * takes no actor, and it can only ever make the answer HARSHER — "this names nothing" instead of
250
+ * "you cannot see it". Nothing about a node it does hold reaches the caller, so no drawing and no
251
+ * message becomes kinder. Adding a third would need the same three properties (#492, #509).
231
252
  */
232
253
  visibleNodes(actor: FlowActor, nodeId: string): Promise<Node | null>;
233
254
  toolSurfaceFingerprint(actor: FlowActor, server: string, allow: string[] | null): Promise<string | null>;
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
@@ -97,7 +97,35 @@ export function createIntel(deps) {
97
97
  return context.redirect("/tools?connectError=portal_sign_in_failed");
98
98
  }
99
99
  });
100
- app.get("/auth/callback", async (context) => await browserAuth.callback(new URL(context.req.url), context.req.raw.headers));
100
+ // The other end of the same walk: `/auth/connect` starts it, `/auth/callback` finishes it, and
101
+ // a reader who lands in a white JSON page does not distinguish which of the two put them there
102
+ // (#444). Everything that reaches this catch has already lost its handoff — `callback` answers
103
+ // the paths that still know where the person wanted to go — so the destination here is a
104
+ // DECIDED one rather than a remembered one.
105
+ //
106
+ // ⚠️ `/tools` and not `/`: it is the only screen that reads `connectError` and turns it into a
107
+ // sentence (`portalAnswerOf`), and every code but the two about access lands on "the sign-in is
108
+ // broken" there — which is what happened. The root would take the reader somewhere that says
109
+ // nothing at all about the attempt they just made. Somebody who has no Intel session either is
110
+ // sent on to the login by the interface's own 401 handling, once, bounded by the sign-in loop
111
+ // guard (#118).
112
+ app.get("/auth/callback", async (context) => {
113
+ try {
114
+ return await browserAuth.callback(new URL(context.req.url), context.req.raw.headers);
115
+ }
116
+ catch (error) {
117
+ // ⚠️ The IntelError is logged here although `app.onError` deliberately does not log one:
118
+ // an expected refusal explains itself through its body, and this route no longer has a
119
+ // body. `oauth_session_invalid` is raised for two different situations — no readable
120
+ // handoff at all, and a Gate login handoff that ran out — and a deployment where EVERY
121
+ // callback fails (a rotated session key, a clock that drifted, a cookie the browser stopped
122
+ // sending) looks from the outside exactly like one person with a stale tab. Without this
123
+ // line nothing anywhere tells the two apart.
124
+ reportUnexpectedError(error);
125
+ const code = error instanceof IntelError ? error.code : "portal_sign_in_failed";
126
+ return context.redirect(`/tools?connectError=${encodeURIComponent(code)}`);
127
+ }
128
+ });
101
129
  app.post("/auth/logout", () => browserAuth.logout());
102
130
  }
103
131
  app.route("/api/v1", createHttp({