@anchrd/intel-api 0.4.0 → 0.5.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.
@@ -3,7 +3,7 @@ const grantColumns = `id, node_id, principal_type, principal_id, verb, expires_a
3
3
  created_by, created_at`;
4
4
  const linkColumns = `link.id, link.source_node_id, link.target_node_id, link.relation,
5
5
  link.origin, link.label, link.created_by, link.created_at`;
6
- const nodeColumns = `n.id, n.parent_id, n.kind, n.title, n.description, n.context_policy,
6
+ const nodeColumns = `n.id, n.parent_id, n.kind, n.title, n.description,
7
7
  n.owner_id, n.current_version_id, n.created_at, n.updated_at, n.archived_at`;
8
8
  function mapNode(row) {
9
9
  return {
@@ -12,7 +12,6 @@ function mapNode(row) {
12
12
  kind: row.kind,
13
13
  title: row.title,
14
14
  description: row.description,
15
- contextPolicy: row.context_policy,
16
15
  ownerId: row.owner_id,
17
16
  currentVersionId: row.current_version_id,
18
17
  createdAt: row.created_at,
@@ -153,10 +152,10 @@ export function createKnowledgeRepository(deps) {
153
152
  await deps.db.batch([
154
153
  deps.db
155
154
  .prepare(`INSERT INTO knowledge_nodes (
156
- id, parent_id, kind, title, description, context_policy, owner_id,
155
+ id, parent_id, kind, title, description, owner_id,
157
156
  current_version_id, created_at, updated_at, archived_at
158
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
159
- .bind(node.id, node.parentId, node.kind, node.title, node.description, node.contextPolicy, node.ownerId, node.currentVersionId, node.createdAt, node.updatedAt, node.archivedAt),
157
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
158
+ .bind(node.id, node.parentId, node.kind, node.title, node.description, node.ownerId, node.currentVersionId, node.createdAt, node.updatedAt, node.archivedAt),
160
159
  deps.db
161
160
  .prepare(`INSERT INTO idempotency_keys (
162
161
  actor_id, operation, idempotency_key, resource_id, created_at
@@ -239,10 +238,10 @@ export function createKnowledgeRepository(deps) {
239
238
  JOIN descendants parent ON child.parent_id = parent.id
240
239
  )
241
240
  UPDATE knowledge_nodes
242
- SET parent_id = ?, title = ?, description = ?, context_policy = ?, updated_at = ?
241
+ SET parent_id = ?, title = ?, description = ?, updated_at = ?
243
242
  WHERE id = ? AND updated_at = ?
244
243
  AND (? IS NULL OR ? NOT IN (SELECT id FROM descendants))`)
245
- .bind(node.id, node.parentId, node.title, node.description, node.contextPolicy, node.updatedAt, node.id, input.baseUpdatedAt, node.parentId, node.parentId),
244
+ .bind(node.id, node.parentId, node.title, node.description, node.updatedAt, node.id, input.baseUpdatedAt, node.parentId, node.parentId),
246
245
  deps.db
247
246
  .prepare(`INSERT INTO idempotency_keys (
248
247
  actor_id, operation, idempotency_key, resource_id, created_at
@@ -250,9 +249,9 @@ export function createKnowledgeRepository(deps) {
250
249
  WHERE EXISTS (
251
250
  SELECT 1 FROM knowledge_nodes
252
251
  WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ?
253
- AND context_policy = ? AND updated_at = ?
252
+ AND updated_at = ?
254
253
  )`)
255
- .bind(input.actorId, input.idempotencyKey, node.id, node.updatedAt, node.id, node.parentId, node.title, node.description, node.contextPolicy, node.updatedAt),
254
+ .bind(input.actorId, input.idempotencyKey, node.id, node.updatedAt, node.id, node.parentId, node.title, node.description, node.updatedAt),
256
255
  deps.db
257
256
  .prepare(`INSERT INTO audit_events (
258
257
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
@@ -265,7 +264,6 @@ export function createKnowledgeRepository(deps) {
265
264
  .bind(input.auditId, input.actorId, node.id, JSON.stringify({
266
265
  parentId: node.parentId,
267
266
  title: node.title,
268
- contextPolicy: node.contextPolicy,
269
267
  }), node.updatedAt, input.actorId, input.idempotencyKey, node.id),
270
268
  ]);
271
269
  }
package/dist/cli/cli.js CHANGED
@@ -3,14 +3,18 @@ import { createPrepare } from "../prepare/prepare.js";
3
3
  const interfaces = [
4
4
  { handle: "intel", functions: ["use", "admin"] },
5
5
  { handle: "knowledge", functions: ["read", "create", "write", "share"] },
6
+ // ⚠️ No `approve`. The approval node is gone (#73), and this list is what Intel declares to Gate:
7
+ // a function nobody asks about is a permission an operator has to decide on for no reason. An
8
+ // installation that already granted it keeps a harmless leftover — bootstrap declares, it does
9
+ // not revoke.
6
10
  {
7
11
  handle: "flows",
8
- functions: ["read", "create", "write", "publish", "run", "approve", "share"],
12
+ functions: ["read", "create", "write", "publish", "run", "share"],
9
13
  },
10
14
  { handle: "tools", functions: ["read", "test", "execute", "admin"] },
11
- // `mcp:connect` heisst bei jedem MCP-Dienst dieser Installation gleich. Ein eigener Name fuer
12
- // dieselbe Sache zwingt den Betreiber, pro Dienst nachzusehen, welches Recht den Portal-Zugang
13
- // traegt.
15
+ // `mcp:connect` means the same thing at every MCP service of this installation. A separate name
16
+ // for the same thing forces the operator to check, service by service, which permission carries
17
+ // portal access.
14
18
  { handle: "mcp", functions: ["connect"] },
15
19
  ];
16
20
  const usage = `Usage: intel <prepare|bootstrap|build|doctor|reindex>
@@ -4,11 +4,13 @@ type SubflowNode = Extract<FlowNode, {
4
4
  kind: "subflow";
5
5
  }>;
6
6
  type KnowledgeStepNode = Extract<FlowNode, {
7
- kind: "knowledge";
7
+ kind: KnowledgeLinkKind;
8
8
  }>;
9
9
  type ToolStepNode = Extract<FlowNode, {
10
10
  kind: "tool";
11
11
  }>;
12
+ declare const knowledgeLinkKinds: readonly ["folder", "document", "upload", "table"];
13
+ type KnowledgeLinkKind = (typeof knowledgeLinkKinds)[number];
12
14
  /**
13
15
  * One place reads a graph for each kind of step it contains, and everything else is derived from
14
16
  * these three. The publish-time rule, the freeze, the sidebar, the relation graph, the requirements
@@ -18,6 +20,7 @@ type ToolStepNode = Extract<FlowNode, {
18
20
  export declare function subflowNodes(graph: FlowGraph): SubflowNode[];
19
21
  export declare function knowledgeNodes(graph: FlowGraph): KnowledgeStepNode[];
20
22
  export declare function toolNodes(graph: FlowGraph): ToolStepNode[];
23
+ export declare function resourceIdOf(node: KnowledgeStepNode): string;
21
24
  export declare function calleeIds(graph: FlowGraph): string[];
22
25
  /**
23
26
  * The Knowledge documents a graph names and the tools it calls, flattened and without repetition.
@@ -1,3 +1,4 @@
1
+ import { flowNodeLayer } from "@anchrd/intel-contract";
1
2
  import { IntelError } from "../shared/intel-error/intel-error.js";
2
3
  function invalid(detail) {
3
4
  throw new IntelError(400, "flow_graph_invalid", detail);
@@ -5,6 +6,10 @@ function invalid(detail) {
5
6
  // A call chain deeper than this is a runaway rather than a design. It also bounds the publish-time
6
7
  // walk and the run trail, both of which follow data that other writers can change.
7
8
  const MaxCallDepth = 20;
9
+ // The four link kinds that name something in the shared tree (D25). `tool` is a link too, but it
10
+ // names a portal tool rather than a resource, and every rule about reachability applies to these
11
+ // four and not to it.
12
+ const knowledgeLinkKinds = ["folder", "document", "upload", "table"];
8
13
  /**
9
14
  * One place reads a graph for each kind of step it contains, and everything else is derived from
10
15
  * these three. The publish-time rule, the freeze, the sidebar, the relation graph, the requirements
@@ -14,12 +19,20 @@ const MaxCallDepth = 20;
14
19
  export function subflowNodes(graph) {
15
20
  return graph.nodes.filter((node) => node.kind === "subflow");
16
21
  }
22
+ // ⚠️ Kept as one list although it is now four kinds: every caller asks "what does this graph read
23
+ // from the tree", never "which of the four". Splitting the callers to match the split in the schema
24
+ // would multiply four ways at each of the six call sites for no answer anyone wants.
17
25
  export function knowledgeNodes(graph) {
18
- return graph.nodes.filter((node) => node.kind === "knowledge");
26
+ return graph.nodes.filter((node) => knowledgeLinkKinds.includes(node.kind));
19
27
  }
20
28
  export function toolNodes(graph) {
21
29
  return graph.nodes.filter((node) => node.kind === "tool");
22
30
  }
31
+ // The one resource a link names. Its own function because four kinds answer it the same way, and a
32
+ // caller that switched on the kind to read the same field would invite a fifth kind to be forgotten.
33
+ export function resourceIdOf(node) {
34
+ return node.configuration.resourceId;
35
+ }
23
36
  // The flows a graph calls, in the order the nodes name them and without repetition. `subflowNodes`
24
37
  // answers "which calls", this answers "which flows" — a flow called twice with two different version
25
38
  // choices is two calls and one callee, and the freeze has to see both.
@@ -41,9 +54,8 @@ export function graphReferences(graph) {
41
54
  const knowledge = [];
42
55
  const tools = [];
43
56
  for (const node of knowledgeNodes(graph)) {
44
- for (const resourceId of node.configuration.resourceIds) {
45
- if (!knowledge.includes(resourceId))
46
- knowledge.push(resourceId);
57
+ if (!knowledge.includes(node.configuration.resourceId)) {
58
+ knowledge.push(node.configuration.resourceId);
47
59
  }
48
60
  }
49
61
  for (const node of toolNodes(graph)) {
@@ -118,13 +130,30 @@ export function compileFlow(graph) {
118
130
  for (const node of graph.nodes) {
119
131
  const parents = incoming.get(node.id) ?? 0;
120
132
  const children = outgoing.get(node.id) ?? [];
121
- // An attached node is material, not a step: it is not in the order of work, so the rules about
122
- // what comes before and after it do not apply. It must not be in that order either — a node
123
- // that is both would be two things at once, and the run would have to pick one.
124
- if (attachedTo.has(node.id)) {
133
+ const layer = flowNodeLayer[node.kind];
134
+ const holderId = attachedTo.get(node.id);
135
+ // ⚠️ The layer rule of D25, and the reason it lives here rather than in the editor: a graph
136
+ // arrives over MCP as readily as from the canvas. A link is what a step works with and is never
137
+ // in the order of work; a marker and a step are the order of work and are never material.
138
+ // Before this, a link could stand in the chain — which is how a start with an attachment once
139
+ // began its run at the attachment (#37).
140
+ if (layer === "link" && holderId === undefined) {
141
+ invalid(`Node ${node.id} is a link and must be attached to a step`);
142
+ }
143
+ if (layer !== "link" && holderId !== undefined) {
144
+ invalid(`Node ${node.id} is not a link and cannot be attached to another node`);
145
+ }
146
+ if (holderId !== undefined) {
125
147
  if (parents !== 0 || children.length !== 0) {
126
148
  invalid(`Node ${node.id} is attached as context and cannot also be a step`);
127
149
  }
150
+ const holder = nodes.get(holderId);
151
+ // Only a step holds material. A marker carries nothing at all, and a `subflow` is a call: the
152
+ // flow it names brings its own links, and lending it one from here would be steering another
153
+ // flow from outside.
154
+ if (holder && (flowNodeLayer[holder.kind] !== "step" || holder.kind === "subflow")) {
155
+ invalid(`Node ${node.id} can only be attached to an instruction or a condition`);
156
+ }
128
157
  continue;
129
158
  }
130
159
  if (node.kind === "trigger" && parents !== 0)
@@ -134,7 +163,7 @@ export function compileFlow(graph) {
134
163
  }
135
164
  if (node.kind === "output" && children.length !== 0)
136
165
  invalid(`Output ${node.id} must be terminal`);
137
- if (node.kind === "condition" || node.kind === "approval") {
166
+ if (node.kind === "condition") {
138
167
  if (children.length < 2)
139
168
  invalid(`Node ${node.id} requires at least two branches`);
140
169
  const handles = new Set(children.map((edge) => edge.sourceHandle));
@@ -279,6 +308,95 @@ export function createFlows(deps) {
279
308
  throw new IntelError(403, "flow_execute_forbidden", "Flow execute permission is required");
280
309
  }
281
310
  }
311
+ /**
312
+ * Everything that stands between this actor and a run of this flow, gathered rather than thrown.
313
+ *
314
+ * ⚠️ THE reason this function exists rather than a second list beside `start`: two lists drift.
315
+ * They drift silently, and the direction they drift in is the worst one — `validate` keeps saying
316
+ * "it would run" while `start` refuses, which sends the reader looking anywhere but at the
317
+ * problem. `start` calls this and throws on the first entry; `validate` calls it and returns all
318
+ * of them. A check added here is therefore added to both, or to neither.
319
+ */
320
+ async function collectRunProblems(actor, flow, pinnedVersionId) {
321
+ const problems = [];
322
+ if (!(await deps.repository.can(actor, flow.id, "execute"))) {
323
+ problems.push({
324
+ status: 403,
325
+ code: "flow_execute_forbidden",
326
+ detail: "Flow execute permission is required",
327
+ });
328
+ }
329
+ // Asked even for a frozen call: a flow whose publication was withdrawn is not something a
330
+ // pinned version may quietly keep running.
331
+ if (!flow.publishedVersionId) {
332
+ problems.push({
333
+ status: 409,
334
+ code: "flow_not_published",
335
+ detail: "Flow has no published version",
336
+ });
337
+ return { problems, version: null, first: null };
338
+ }
339
+ // A frozen call runs its own version; everything else runs what is published now.
340
+ const version = await requireVersion(pinnedVersionId ?? flow.publishedVersionId, flow.id);
341
+ let first = null;
342
+ try {
343
+ const compiled = compileFlow(version.graph);
344
+ // ⚠️ Flow edges only. A start carrying context would otherwise begin the run at the attached
345
+ // node, because that edge can come first in the list (#37).
346
+ first =
347
+ version.graph.edges.find((edge) => edge.kind === "flow" && edge.source === compiled.triggerId)?.target ?? null;
348
+ if (!first)
349
+ invalid("The trigger requires an outgoing edge");
350
+ }
351
+ catch (error) {
352
+ problems.push({
353
+ status: 400,
354
+ code: "flow_graph_invalid",
355
+ detail: error instanceof IntelError ? error.message : "The graph cannot be compiled",
356
+ });
357
+ return { problems, version, first: null };
358
+ }
359
+ // The flow may have been built by someone with wider portal access. Naming the missing tools
360
+ // before the first step beats failing halfway through with a portal error the user cannot
361
+ // place — and the portal is where they can do something about it.
362
+ const missing = await deps.unavailableTools(actor, graphReferences(version.graph).tools);
363
+ if (missing.length) {
364
+ problems.push({
365
+ status: 403,
366
+ code: "flow_tools_unavailable",
367
+ detail: toolStepDetail(missing),
368
+ });
369
+ }
370
+ try {
371
+ await requireNodeAuthorized(actor, nodeFor(version, first), version.graph);
372
+ }
373
+ catch (error) {
374
+ if (!(error instanceof IntelError))
375
+ throw error;
376
+ problems.push({ status: error.status, code: error.code, detail: error.message });
377
+ }
378
+ // Every flow this one calls has to be published, or the call fails halfway through a run rather
379
+ // than before it. `publish` refuses this at freeze time; a flow whose callee was un-published
380
+ // afterwards is the case only a check on demand can catch.
381
+ for (const node of subflowNodes(version.graph)) {
382
+ const callee = await deps.repository.getCallable(actor, node.configuration.flowId);
383
+ if (!callee || callee.archivedAt) {
384
+ problems.push({
385
+ status: 409,
386
+ code: "flow_subflow_unavailable",
387
+ detail: `The called flow is not available: ${node.label}`,
388
+ });
389
+ }
390
+ else if (!callee.publishedVersionId) {
391
+ problems.push({
392
+ status: 409,
393
+ code: "flow_subflow_not_published",
394
+ detail: `The called flow has no published version: ${node.label}`,
395
+ });
396
+ }
397
+ }
398
+ return { problems, version, first };
399
+ }
282
400
  // A flow's parent is a Knowledge folder, so the answer comes from Knowledge rather than from a
283
401
  // second permission model here. `null` is the root and needs no permission of its own — the same
284
402
  // as creating a folder at the root does.
@@ -363,16 +481,27 @@ export function createFlows(deps) {
363
481
  // current Gate identity and the resource ACL are asked again. A called flow's steps come through
364
482
  // this same function, so a document the user may not read stays unreadable however deep the call
365
483
  // sits, and a flow grant keeps protecting the procedure rather than the data.
366
- async function requireNodeAuthorized(actor, node) {
367
- if (node?.kind === "knowledge") {
368
- const wanted = [...new Set(node.configuration.resourceIds)];
484
+ // ⚠️ Reads what hangs off the step, not the step itself (D25). A document or a tool is a link on a
485
+ // `context` edge now, and a run never stands on one — so asking "is this node a knowledge step"
486
+ // would ask about a node the run can no longer reach, and every check here would silently pass.
487
+ async function requireNodeAuthorized(actor, node, graph) {
488
+ if (!node)
489
+ return;
490
+ const attached = graph.edges
491
+ .filter((edge) => edge.kind === "context" && edge.source === node.id)
492
+ .flatMap((edge) => graph.nodes.filter((candidate) => candidate.id === edge.target));
493
+ const wanted = [...new Set(knowledgeNodes({ ...graph, nodes: attached }).map(resourceIdOf))];
494
+ if (wanted.length) {
369
495
  const reachable = await reachableKnowledge(actor, wanted);
370
496
  if (reachable.length !== wanted.length) {
371
497
  throw new IntelError(403, "flow_knowledge_forbidden", knowledgeStepDetail(node.label, wanted.length - reachable.length));
372
498
  }
373
499
  }
374
- if (node?.kind === "tool") {
375
- const missing = await deps.unavailableTools(actor, [node.configuration.toolName]);
500
+ const toolNames = attached
501
+ .filter((candidate) => candidate.kind === "tool")
502
+ .map((candidate) => candidate.configuration.toolName);
503
+ if (toolNames.length) {
504
+ const missing = await deps.unavailableTools(actor, [...new Set(toolNames)]);
376
505
  if (missing.length) {
377
506
  throw new IntelError(403, "flow_tools_unavailable", toolStepDetail(missing));
378
507
  }
@@ -404,7 +533,7 @@ export function createFlows(deps) {
404
533
  await requireRunnableFlow(actor, run.flowId);
405
534
  const version = await requireVersion(run.versionId, run.flowId);
406
535
  const node = nodeFor(version, run.currentNodeId);
407
- await requireNodeAuthorized(actor, node);
536
+ await requireNodeAuthorized(actor, node, version.graph);
408
537
  return { run: await narrowed(actor, run, version), node, trail: await trailFor(run, version) };
409
538
  }
410
539
  // A failed run carries the text of the step that failed, and for a call that text came out of
@@ -571,7 +700,7 @@ export function createFlows(deps) {
571
700
  throw new IntelError(403, "flow_call_site_forbidden", "The calling run belongs to someone else");
572
701
  }
573
702
  await requireExecute(actor, run.flowId);
574
- if (run.status !== "running" && run.status !== "waiting") {
703
+ if (run.status !== "running") {
575
704
  throw new IntelError(409, "flow_run_terminal", "Flow run is already terminal");
576
705
  }
577
706
  if (run.currentNodeId !== parent.nodeId) {
@@ -715,17 +844,16 @@ export function createFlows(deps) {
715
844
  // name the step it comes from, so this is the one caller that needs the nodes themselves
716
845
  // and not the flattened `graphReferences`.
717
846
  for (const node of knowledgeNodes(version.graph)) {
718
- for (const resourceId of node.configuration.resourceIds) {
719
- const target = await knowledgeNode(resourceId);
720
- if (!target || !place(target.node, target.parentId))
721
- continue;
722
- edges.push({
723
- id: `reads:${flow.id}:${node.id}:${resourceId}`,
724
- source: flow.id,
725
- target: resourceId,
726
- relation: "reads",
727
- });
728
- }
847
+ const resourceId = resourceIdOf(node);
848
+ const target = await knowledgeNode(resourceId);
849
+ if (!target || !place(target.node, target.parentId))
850
+ continue;
851
+ edges.push({
852
+ id: `reads:${flow.id}:${node.id}:${resourceId}`,
853
+ source: flow.id,
854
+ target: resourceId,
855
+ relation: "reads",
856
+ });
729
857
  }
730
858
  for (const node of subflowNodes(version.graph)) {
731
859
  const callee = calleeFlow(node.configuration.flowId);
@@ -779,6 +907,28 @@ export function createFlows(deps) {
779
907
  // and no claim about whether anyone may reach it. A standing "this flow has conflicts" badge
780
908
  // would be wrong for tools by construction — the catalog is a live query with the requesting
781
909
  // user's own token (ADR-0003) — and out of date for documents most of the time.
910
+ /**
911
+ * Would this flow start, for the person asking, right now? It answers and changes nothing: no
912
+ * run row, no idempotency key, no audit event (#72).
913
+ *
914
+ * ⚠️ `read` is enough, deliberately. Somebody who may see a flow but not run it is exactly who
915
+ * needs this — "you are missing execute" is the answer they came for, and refusing to answer
916
+ * would leave them guessing at the one thing that is easy to say. Nothing is named here that a
917
+ * reader could not already read out of the graph.
918
+ *
919
+ * ⚠️ Not cached and not stored anywhere. The tool catalog is a live query with the asking
920
+ * user's own token (ADR-0003); a kept answer would be a claim about a moment that has passed.
921
+ */
922
+ async validate(actor, flowId) {
923
+ const flow = await requireRunnableFlow(actor, flowId);
924
+ const { problems, version } = await collectRunProblems(actor, flow, null);
925
+ return {
926
+ flowId: flow.id,
927
+ versionId: version?.id ?? null,
928
+ problems: problems.map(({ code, detail }) => ({ code, detail })),
929
+ checkedAt: deps.now().toISOString(),
930
+ };
931
+ },
782
932
  async listRequirements(actor, flowId) {
783
933
  const flow = await requireFlow(actor, flowId);
784
934
  // The draft is what the editor is looking at; a flow with only a published version has
@@ -915,15 +1065,14 @@ export function createFlows(deps) {
915
1065
  return await requireFlow(actor, flow.id);
916
1066
  const version = await requireVersion(input.versionId, flow.id);
917
1067
  compileFlow(version.graph);
918
- for (const node of version.graph.nodes) {
919
- if (node.kind === "knowledge") {
920
- const reachable = new Set((await reachableKnowledge(actor, node.configuration.resourceIds)).map((reference) => reference.id));
921
- for (const resourceId of node.configuration.resourceIds) {
922
- if (!reachable.has(resourceId)) {
923
- throw new IntelError(409, "flow_knowledge_unavailable", `Knowledge reference is unavailable: ${resourceId}`);
924
- }
925
- }
1068
+ const wanted = [...new Set(knowledgeNodes(version.graph).map(resourceIdOf))];
1069
+ const reachable = new Set((await reachableKnowledge(actor, wanted)).map((reference) => reference.id));
1070
+ for (const resourceId of wanted) {
1071
+ if (!reachable.has(resourceId)) {
1072
+ throw new IntelError(409, "flow_knowledge_unavailable", `Knowledge reference is unavailable: ${resourceId}`);
926
1073
  }
1074
+ }
1075
+ for (const node of version.graph.nodes) {
927
1076
  if (node.kind === "tool") {
928
1077
  const fingerprint = await deps.toolFingerprint(actor, node.configuration.toolName);
929
1078
  if (!fingerprint) {
@@ -1020,46 +1169,41 @@ export function createFlows(deps) {
1020
1169
  }
1021
1170
  }
1022
1171
  const flow = await requireRunnableFlow(actor, input.flowId);
1023
- // Asked even for a frozen call: a flow whose publication was withdrawn is not something a
1024
- // pinned version may quietly keep running.
1025
- if (!flow.publishedVersionId) {
1026
- throw new IntelError(409, "flow_not_published", "Flow has no published version");
1027
- }
1028
- // A frozen call runs its own version; everything else runs what is published now. Both land in
1029
- // `run.versionId` below, so a run always records which version actually ran (ADR-0004 §5).
1030
- const version = await requireVersion(call?.versionId ?? flow.publishedVersionId, flow.id);
1031
- const compiled = compileFlow(version.graph);
1032
- // ⚠️ Flow edges only. A start carrying context would otherwise begin the run at the attached
1033
- // node, because that edge can come first in the list (#37).
1034
- const first = version.graph.edges.find((edge) => edge.kind === "flow" && edge.source === compiled.triggerId)?.target;
1035
- if (!first)
1036
- invalid("The trigger requires an outgoing edge");
1037
- // The flow may have been built by someone with wider portal access. Naming the missing tools
1038
- // before the first step beats failing halfway through with a portal error the user cannot
1039
- // place — and the portal is where they can do something about it.
1040
- const missing = await deps.unavailableTools(actor, graphReferences(version.graph).tools);
1041
- if (missing.length) {
1042
- throw new IntelError(403, "flow_tools_unavailable", toolStepDetail(missing));
1043
- }
1044
- // The first step is authorized before a run row exists, so a refusal leaves nothing behind at
1045
- // all — not a run, not an idempotency key, not an audit event.
1046
- await requireNodeAuthorized(actor, nodeFor(version, first));
1172
+ // ⚠️ The same list `validate` reads, and the reason it is one list: a check that only one of
1173
+ // them knows is a check the other silently skips. Thrown here at the first entry, because a
1174
+ // start is an action and an action either happens or says why not.
1175
+ //
1176
+ // Nothing has been written at this point, so a refusal leaves nothing behind at all — not a
1177
+ // run, not an idempotency key, not an audit event.
1178
+ const check = await collectRunProblems(actor, flow, call?.versionId ?? null);
1179
+ const problem = check.problems[0];
1180
+ if (problem)
1181
+ throw new IntelError(problem.status, problem.code, problem.detail);
1182
+ const version = check.version;
1183
+ const first = check.first;
1184
+ if (!version || !first)
1185
+ invalid("The flow cannot be started");
1047
1186
  const occurredAt = deps.now().toISOString();
1187
+ // ⚠️ A flow whose start leads straight to its end has nothing to carry out, and the end is a
1188
+ // marker rather than a step (D25). It is the shape every flow has the moment it is created,
1189
+ // so it must run rather than park a run on a node nobody can complete. What it hands back is
1190
+ // its input: the only thing that passed through it.
1191
+ const empty = nodeFor(version, first)?.kind === "output";
1048
1192
  const run = {
1049
1193
  id: deps.id(),
1050
1194
  flowId: flow.id,
1051
1195
  versionId: version.id,
1052
- status: "running",
1053
- currentNodeId: first,
1196
+ status: empty ? "completed" : "running",
1197
+ currentNodeId: empty ? null : first,
1054
1198
  input: input.input,
1055
- output: null,
1199
+ output: empty ? input.input : null,
1056
1200
  error: null,
1057
1201
  initiatedBy: actor.id,
1058
1202
  parentRunId: parent?.id ?? null,
1059
1203
  parentNodeId: parent ? (input.parent?.nodeId ?? null) : null,
1060
1204
  createdAt: occurredAt,
1061
1205
  updatedAt: occurredAt,
1062
- completedAt: null,
1206
+ completedAt: empty ? occurredAt : null,
1063
1207
  };
1064
1208
  const inserted = await deps.repository.insertRun({
1065
1209
  run,
@@ -1218,10 +1362,10 @@ export function createFlows(deps) {
1218
1362
  const current = await deps.repository.getRunVisible(actor, input.runId);
1219
1363
  if (!current)
1220
1364
  throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
1221
- // Rechecked here rather than trusted from the start: a grant revoked while a run waits for an
1222
- // approval must stop the next step, not only the next run.
1365
+ // Rechecked here rather than trusted from the start: a grant revoked between two steps must
1366
+ // stop the next one, not only the next run.
1223
1367
  await requireExecute(actor, current.flowId);
1224
- if (current.status !== "running" && current.status !== "waiting") {
1368
+ if (current.status !== "running") {
1225
1369
  throw new IntelError(409, "flow_run_terminal", "Flow run is already terminal");
1226
1370
  }
1227
1371
  if (current.currentNodeId !== input.nodeId) {
@@ -1232,12 +1376,9 @@ export function createFlows(deps) {
1232
1376
  if (!node) {
1233
1377
  throw new IntelError(500, "flow_version_corrupt", "Current run node is missing from the immutable version");
1234
1378
  }
1235
- if (node.kind === "approval" && !actor.canApprove) {
1236
- throw new IntelError(403, "flow_approval_forbidden", "Approval permission is required");
1237
- }
1238
1379
  // Asked again before the result is recorded, not only when the step was handed out: a grant
1239
1380
  // revoked while the step was being carried out must stop it from landing.
1240
- await requireNodeAuthorized(actor, node);
1381
+ await requireNodeAuthorized(actor, node, version.graph);
1241
1382
  // ⚠️ A subflow step is finished by the run it started, and by nothing the caller of this
1242
1383
  // surface says. Outcome, output and error are all read back out of that run: the outcome too,
1243
1384
  // or a client could mark a call that succeeded as failed and send the flow down a branch the
@@ -1275,22 +1416,28 @@ export function createFlows(deps) {
1275
1416
  error = stepError ?? StepFailedDetail;
1276
1417
  completedAt = occurredAt;
1277
1418
  }
1278
- else if (node.kind === "output") {
1279
- status = "completed";
1280
- output = stepOutput;
1281
- completedAt = occurredAt;
1282
- }
1283
1419
  else {
1284
1420
  // ⚠️ Flow edges only, for the same reason as the start above: what a step works with is not
1285
1421
  // where the run goes next (#37).
1286
1422
  const edges = version.graph.edges.filter((edge) => edge.kind === "flow" && edge.source === node.id);
1287
- const edge = node.kind === "condition" || node.kind === "approval"
1423
+ const edge = node.kind === "condition"
1288
1424
  ? edges.find((candidate) => candidate.sourceHandle === input.branch)
1289
1425
  : edges[0];
1290
1426
  if (!edge) {
1291
1427
  throw new IntelError(400, "flow_branch_invalid", "A valid branch is required");
1292
1428
  }
1293
- currentNodeId = edge.target;
1429
+ // ⚠️ The end is reached, never carried out (D25). It marks where the flow stops and holds
1430
+ // nothing to do, so handing it out as a step would ask the agent to perform an empty node
1431
+ // and then wait for the answer. The run ends here instead, with what the step that just
1432
+ // finished handed in — which is what "the result of a run" now means.
1433
+ if (nodeFor(version, edge.target)?.kind === "output") {
1434
+ status = "completed";
1435
+ output = stepOutput;
1436
+ completedAt = occurredAt;
1437
+ }
1438
+ else {
1439
+ currentNodeId = edge.target;
1440
+ }
1294
1441
  }
1295
1442
  const next = {
1296
1443
  ...current,
@@ -1,4 +1,4 @@
1
- import type { CompleteFlowRunStepInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowPublishPreview, FlowRequirements, FlowRun, FlowRunHistory, FlowRunList, FlowRunStep, FlowVersion, KnowledgeNode, ListFlowRunsInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraph, RelationGraphInput, ResourceVerb, SaveFlowVersionInput, StartFlowRunInput, UpdateFlowInput } from "@anchrd/intel-contract";
1
+ import type { CompleteFlowRunStepInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowPublishPreview, FlowRequirements, FlowRun, FlowRunHistory, FlowRunList, FlowRunStep, FlowValidation, FlowVersion, KnowledgeNode, ListFlowRunsInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraph, RelationGraphInput, ResourceVerb, SaveFlowVersionInput, StartFlowRunInput, UpdateFlowInput } from "@anchrd/intel-contract";
2
2
  export type FlowPrincipal = Pick<FlowActor, "id" | "email" | "isAdmin">;
3
3
  export type FlowCallReach = "subtree" | "library" | "out-of-reach";
4
4
  export interface FlowRunChainEntry {
@@ -37,7 +37,6 @@ export interface FlowActor {
37
37
  id: string;
38
38
  email: string;
39
39
  canRun: boolean;
40
- canApprove: boolean;
41
40
  isAdmin?: boolean;
42
41
  }
43
42
  export type FlowVerb = Extract<ResourceVerb, "read" | "write" | "execute">;
@@ -163,6 +162,7 @@ export interface FlowService {
163
162
  listCalls(actor: FlowActor, flowId: string): Promise<{
164
163
  items: Flow[];
165
164
  }>;
165
+ validate(actor: FlowActor, flowId: string): Promise<FlowValidation>;
166
166
  relationGraph(actor: FlowActor, input: RelationGraphInput): Promise<RelationGraph>;
167
167
  listRequirements(actor: FlowActor, flowId: string): Promise<FlowRequirements>;
168
168
  create(actor: FlowActor, input: CreateFlowInput): Promise<Flow>;
@@ -180,3 +180,8 @@ export interface CompiledFlow {
180
180
  graph: FlowGraph;
181
181
  triggerId: string;
182
182
  }
183
+ export interface RunProblem {
184
+ status: number;
185
+ code: string;
186
+ detail: string;
187
+ }
package/dist/http/http.js CHANGED
@@ -48,7 +48,6 @@ function asFlowActor(authorization) {
48
48
  id: authorization.identity.id,
49
49
  email: authorization.identity.email,
50
50
  canRun: permits(authorization, "flows", "run"),
51
- canApprove: permits(authorization, "flows", "approve"),
52
51
  isAdmin: authorization.can("intel", "admin"),
53
52
  };
54
53
  }
@@ -283,6 +282,15 @@ export function createHttp(deps) {
283
282
  const input = GetFlowInput.parse({ flowId: context.req.param("flowId") });
284
283
  return context.json(await deps.flows.listCalls(asFlowActor(auth), input.flowId));
285
284
  });
285
+ // Would this flow start, for whoever is asking, right now (#72). `flows/read` and nothing more:
286
+ // the answer somebody without `execute` needs most is that they are missing `execute`, and it
287
+ // names nothing they could not read out of the graph. A GET because it changes nothing — no run,
288
+ // no key, no audit event.
289
+ app.get("/flows/:flowId/validation", async (context) => {
290
+ const auth = requireCapability(context, "flows", "read");
291
+ const input = GetFlowInput.parse({ flowId: context.req.param("flowId") });
292
+ return context.json(await deps.flows.validate(asFlowActor(auth), input.flowId));
293
+ });
286
294
  // What this flow needs: the documents and tools its graph names. `flows/read` and nothing more —
287
295
  // it describes a flow the caller may already open, and names only what they may already see.
288
296
  app.get("/flows/:flowId/requirements", async (context) => {
@@ -323,7 +323,6 @@ export function createKnowledge(deps) {
323
323
  kind: input.kind,
324
324
  title: input.title,
325
325
  description: input.description,
326
- contextPolicy: input.contextPolicy,
327
326
  ownerId: actor.id,
328
327
  currentVersionId: null,
329
328
  createdAt: timestamp,
@@ -553,7 +552,6 @@ export function createKnowledge(deps) {
553
552
  parentId: input.parentId === undefined ? current.parentId : input.parentId,
554
553
  title: input.title ?? current.title,
555
554
  description: input.description === undefined ? current.description : input.description,
556
- contextPolicy: input.contextPolicy ?? current.contextPolicy,
557
555
  updatedAt,
558
556
  },
559
557
  baseUpdatedAt: input.baseUpdatedAt,
package/dist/mcp/mcp.js CHANGED
@@ -39,7 +39,6 @@ export async function handleMcp(request, deps) {
39
39
  id: deps.authorization.identity.id,
40
40
  email: deps.authorization.identity.email,
41
41
  canRun: permits(deps.authorization, "flows", "run"),
42
- canApprove: permits(deps.authorization, "flows", "approve"),
43
42
  isAdmin: deps.authorization.can("intel", "admin"),
44
43
  };
45
44
  const server = new McpServer({ name: "intel", version: "0.1.0" });
@@ -381,6 +380,18 @@ export async function handleMcp(request, deps) {
381
380
  openWorldHint: false,
382
381
  },
383
382
  }, async (input) => text(await deps.flows.relationGraph(flowActor, input)));
383
+ server.registerTool("flow_validate", {
384
+ title: "Check whether a flow would start",
385
+ description: "Check whether this flow would start for the calling user right now, and name every reason it would not. Answers rather than acts: no run is created. The answer is a snapshot for this user at this moment, because tool reachability is a live query with their own portal token.",
386
+ inputSchema: GetFlowInput,
387
+ annotations: {
388
+ title: "Check whether a flow would start",
389
+ readOnlyHint: true,
390
+ destructiveHint: false,
391
+ idempotentHint: true,
392
+ openWorldHint: false,
393
+ },
394
+ }, async (input) => text(await deps.flows.validate(flowActor, input.flowId)));
384
395
  server.registerTool("flow_requirements_list", {
385
396
  title: "List what a flow needs",
386
397
  description: "List the Knowledge documents and MCP tools one flow's graph names. Documents the calling user cannot see are counted rather than named, and no claim is made about whether anyone may reach them: for tools that cannot be known in advance, because the catalog is a live query with each user's own portal token.",
@@ -0,0 +1,69 @@
1
+ -- #73. The approval node leaves the contract, so the graphs already stored are rewritten to match
2
+ -- the schema that will read them. Without this an `approval` node makes `FlowGraph.parse` throw in
3
+ -- `db-flows.ts`, and the flow cannot be opened at all — not even to repair it. A removal that locks
4
+ -- people out of their own flows is worse than the node it removes.
5
+ --
6
+ -- ⚠️ Rewritten to a `condition`, not deleted. The node has two outgoing branches, and deleting it
7
+ -- would leave two edges pointing nowhere and a graph that fails `compileFlow` for a second reason.
8
+ -- A condition is the honest replacement: it branches the same way, and the question the approval
9
+ -- asked — "may this go ahead?" — is one the agent carrying out the run can answer. The prompt is
10
+ -- carried over into the instruction rather than dropped, so the author still reads what was meant.
11
+ -- `timeout` disappears with the waiting it configured (D24).
12
+ --
13
+ -- ⚠️ The branch handles are rewritten with it: an approval branched on `approved`/`rejected`, a
14
+ -- condition on `yes`/`no`. Left alone, `completeStep` would look for a handle that no edge carries
15
+ -- and every run through that node would answer 400.
16
+ --
17
+ -- ⚠️ One pass over the arrays rather than targeted updates, for the same reason as 0005: re-running
18
+ -- must change nothing. A graph with no approval node is written back identical to itself.
19
+ UPDATE flow_versions
20
+ SET graph_json = json_set(
21
+ json_set(
22
+ graph_json,
23
+ '$.nodes',
24
+ (
25
+ SELECT json_group_array(
26
+ CASE
27
+ WHEN json_extract(node.value, '$.kind') = 'approval'
28
+ THEN json_set(
29
+ json_set(node.value, '$.kind', 'condition'),
30
+ '$.configuration',
31
+ json_object(
32
+ 'mode', 'semantic',
33
+ 'instruction', json_extract(node.value, '$.configuration.prompt')
34
+ )
35
+ )
36
+ ELSE node.value
37
+ END
38
+ )
39
+ FROM json_each(flow_versions.graph_json, '$.nodes') AS node
40
+ )
41
+ ),
42
+ '$.edges',
43
+ (
44
+ SELECT json_group_array(
45
+ CASE json_extract(edge.value, '$.sourceHandle')
46
+ WHEN 'approved' THEN json_set(edge.value, '$.sourceHandle', 'yes')
47
+ WHEN 'rejected' THEN json_set(edge.value, '$.sourceHandle', 'no')
48
+ ELSE edge.value
49
+ END
50
+ )
51
+ FROM json_each(flow_versions.graph_json, '$.edges') AS edge
52
+ )
53
+ )
54
+ WHERE EXISTS (
55
+ SELECT 1 FROM json_each(flow_versions.graph_json, '$.nodes') AS node
56
+ WHERE json_extract(node.value, '$.kind') = 'approval'
57
+ );
58
+
59
+ -- `waiting` was written for the approval node and never set by anything: the two places that tested
60
+ -- for it only ever saw `running`. It leaves `FlowRunStatus`, so a row carrying it would become a
61
+ -- status the contract cannot parse. There should be none — this makes that true rather than hoped.
62
+ --
63
+ -- The column's CHECK constraint still allows the value, deliberately: changing it means rebuilding
64
+ -- the table in D1 for a value nothing writes.
65
+ UPDATE flow_runs
66
+ SET status = 'failed',
67
+ error = 'Flow run was waiting for an approval that no longer exists',
68
+ completed_at = COALESCE(completed_at, updated_at)
69
+ WHERE status = 'waiting';
@@ -0,0 +1,130 @@
1
+ -- #74. Three kinds change shape at once, so the stored graphs are rewritten before the schema that
2
+ -- reads them changes. Without this they stop parsing in `db-flows.ts` and the flows become
3
+ -- unopenable — the trap 0007 was written for, at a larger scale.
4
+ --
5
+ -- ⚠️ A `knowledge` node was BOTH a step and its material. Under D25 those are two layers, so it
6
+ -- becomes two nodes: the step stays exactly where it stood in the chain and turns into an
7
+ -- `instruction`, and each reference it held becomes a link hanging off it on a context edge.
8
+ --
9
+ -- ▶ ── knowledge[a,b] ── ▶ becomes ▶ ── instruction ── ▶
10
+ -- │ │
11
+ -- link a link b
12
+ --
13
+ -- Splitting this way rather than turning the node itself into a link is what keeps every existing
14
+ -- edge valid: nothing is re-pointed, nothing is deleted, and a knowledge node sitting directly
15
+ -- after the start still works — as a link it would have had to attach to the start, which is a
16
+ -- marker and holds nothing. It is also the honest reading. The old node WAS a step; what it lacked
17
+ -- was anything saying what to do with the material, which is exactly what the instruction now says.
18
+ --
19
+ -- ⚠️ The kind a reference points at was never stored in the graph — the node only held ids. The
20
+ -- knowledge tree knows, so the rewrite reads it there. A reference whose target is gone becomes
21
+ -- `document`, which is what the old node treated everything as.
22
+
23
+ -- ── The edges: every old edge, plus one context edge per reference ───────────────────────────────
24
+ -- ⚠️ Runs BEFORE the node rewrite, and that order is the whole reason it is readable: while the
25
+ -- knowledge nodes are still knowledge nodes, the id of the step and the list of references sit in
26
+ -- the same row, so the new edge can name both without taking anybody's id apart.
27
+ UPDATE flow_versions
28
+ SET graph_json = json_set(
29
+ graph_json,
30
+ '$.edges',
31
+ (
32
+ SELECT json_group_array(json(value)) FROM (
33
+ SELECT edge.key AS position, 0 AS tier, edge.value AS value
34
+ FROM json_each(flow_versions.graph_json, '$.edges') AS edge
35
+
36
+ UNION ALL
37
+
38
+ SELECT 1000000 + node.key AS position, reference.key + 1 AS tier,
39
+ json_object(
40
+ 'id', json_extract(node.value, '$.id') || '-link-' || reference.key || '-context',
41
+ 'source', json_extract(node.value, '$.id'),
42
+ 'target', json_extract(node.value, '$.id') || '-link-' || reference.key,
43
+ 'kind', 'context',
44
+ 'label', NULL,
45
+ 'sourceHandle', NULL
46
+ ) AS value
47
+ FROM json_each(flow_versions.graph_json, '$.nodes') AS node,
48
+ json_each(json_extract(node.value, '$.configuration.resourceIds')) AS reference
49
+ WHERE json_extract(node.value, '$.kind') = 'knowledge'
50
+ ORDER BY position, tier
51
+ )
52
+ )
53
+ )
54
+ WHERE EXISTS (
55
+ SELECT 1 FROM json_each(flow_versions.graph_json, '$.nodes') AS node
56
+ WHERE json_extract(node.value, '$.kind') = 'knowledge'
57
+ );
58
+
59
+ -- ── The nodes: every old node, plus one link per reference ───────────────────────────────────────
60
+ UPDATE flow_versions
61
+ SET graph_json = json_set(
62
+ graph_json,
63
+ '$.nodes',
64
+ (
65
+ SELECT json_group_array(json(value)) FROM (
66
+ -- The old nodes, in place and in order. A knowledge node becomes the instruction it always
67
+ -- half was; its retrieval hint becomes the prompt, because that is the only sentence the
68
+ -- author ever wrote about what should happen with the material.
69
+ SELECT node.key AS position, 0 AS tier,
70
+ CASE
71
+ WHEN json_extract(node.value, '$.kind') = 'knowledge'
72
+ THEN json_set(
73
+ json_set(node.value, '$.kind', 'instruction'),
74
+ '$.configuration',
75
+ json_object(
76
+ 'prompt',
77
+ COALESCE(
78
+ NULLIF(json_extract(node.value, '$.configuration.query'), ''),
79
+ 'Work with the attached material.'
80
+ )
81
+ )
82
+ )
83
+ -- The end marks and no longer makes: `template` was never read by anything.
84
+ WHEN json_extract(node.value, '$.kind') = 'output'
85
+ THEN json_set(node.value, '$.configuration', json_object())
86
+ ELSE node.value
87
+ END AS value
88
+ FROM json_each(flow_versions.graph_json, '$.nodes') AS node
89
+
90
+ UNION ALL
91
+
92
+ -- One link per reference, laid out below its step so the graph opens readable rather than
93
+ -- stacked. The id is derived from the node it came from, which keeps the rewrite idempotent:
94
+ -- running it twice produces the same ids, and there is no second knowledge node to expand.
95
+ SELECT node.key AS position, 1 AS tier,
96
+ json_object(
97
+ 'id', json_extract(node.value, '$.id') || '-link-' || reference.key,
98
+ 'kind', COALESCE(
99
+ (
100
+ SELECT CASE knowledge_nodes.kind
101
+ WHEN 'folder' THEN 'folder'
102
+ WHEN 'attachment' THEN 'upload'
103
+ WHEN 'table' THEN 'table'
104
+ ELSE 'document'
105
+ END
106
+ FROM knowledge_nodes WHERE knowledge_nodes.id = reference.value
107
+ ),
108
+ 'document'
109
+ ),
110
+ 'label', COALESCE(
111
+ (SELECT knowledge_nodes.title FROM knowledge_nodes WHERE knowledge_nodes.id = reference.value),
112
+ 'Missing reference'
113
+ ),
114
+ 'position', json_object(
115
+ 'x', json_extract(node.value, '$.position.x') + (reference.key * 200),
116
+ 'y', json_extract(node.value, '$.position.y') + 160
117
+ ),
118
+ 'configuration', json_object('resourceId', reference.value)
119
+ ) AS value
120
+ FROM json_each(flow_versions.graph_json, '$.nodes') AS node,
121
+ json_each(json_extract(node.value, '$.configuration.resourceIds')) AS reference
122
+ WHERE json_extract(node.value, '$.kind') = 'knowledge'
123
+ ORDER BY position, tier
124
+ )
125
+ )
126
+ )
127
+ WHERE EXISTS (
128
+ SELECT 1 FROM json_each(flow_versions.graph_json, '$.nodes') AS node
129
+ WHERE json_extract(node.value, '$.kind') IN ('knowledge', 'output')
130
+ );
@@ -0,0 +1,51 @@
1
+ -- #76. `context_policy` leaves the tree. It was written, stored and handed back, and never once
2
+ -- read to decide anything — and it could not have been: it instructed a retrieval Intel does not
3
+ -- perform. Intel hands out references and the agent fetches what it needs (D24).
4
+ --
5
+ -- ⚠️ SQLite cannot drop a column that a CHECK constraint names, and this one names itself. The
6
+ -- table has to be rebuilt, which for `knowledge_nodes` means six foreign keys pointing at it —
7
+ -- including one from itself. The order below is SQLite's own twelve-step recipe, and every line of
8
+ -- it is load-bearing:
9
+ --
10
+ -- * `PRAGMA foreign_keys` is turned off first. Without it the DROP below would either fail or
11
+ -- cascade into the rows of every referencing table.
12
+ -- * The new table is created under a different name and renamed afterwards, rather than the old
13
+ -- one being renamed out of the way. Renaming the OLD table would silently re-point every
14
+ -- foreign key in the other five tables at `knowledge_nodes_old` — SQLite updates references to
15
+ -- follow a rename, and that is exactly what must not happen here.
16
+ -- * The indexes are recreated, because dropping the table drops them with it.
17
+ -- * `PRAGMA foreign_key_check` is the proof, not the hope. The integration test asserts it comes
18
+ -- back empty.
19
+ PRAGMA foreign_keys = OFF;
20
+
21
+ CREATE TABLE knowledge_nodes_rebuilt (
22
+ id TEXT PRIMARY KEY NOT NULL,
23
+ parent_id TEXT REFERENCES knowledge_nodes(id),
24
+ kind TEXT NOT NULL CHECK (kind IN ('folder', 'document', 'attachment', 'table')),
25
+ title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 240),
26
+ description TEXT CHECK (description IS NULL OR length(description) <= 2000),
27
+ owner_id TEXT NOT NULL,
28
+ current_version_id TEXT,
29
+ created_at TEXT NOT NULL,
30
+ updated_at TEXT NOT NULL,
31
+ archived_at TEXT
32
+ );
33
+
34
+ INSERT INTO knowledge_nodes_rebuilt (
35
+ id, parent_id, kind, title, description, owner_id,
36
+ current_version_id, created_at, updated_at, archived_at
37
+ )
38
+ SELECT id, parent_id, kind, title, description, owner_id,
39
+ current_version_id, created_at, updated_at, archived_at
40
+ FROM knowledge_nodes;
41
+
42
+ DROP TABLE knowledge_nodes;
43
+
44
+ ALTER TABLE knowledge_nodes_rebuilt RENAME TO knowledge_nodes;
45
+
46
+ CREATE INDEX knowledge_nodes_parent_idx ON knowledge_nodes(parent_id, archived_at, title);
47
+ CREATE INDEX knowledge_nodes_owner_idx ON knowledge_nodes(owner_id, archived_at);
48
+
49
+ PRAGMA foreign_key_check;
50
+
51
+ PRAGMA foreign_keys = ON;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anchrd/gate-sdk": "^0.7.0",
46
- "@anchrd/intel-contract": "^0.2.0",
46
+ "@anchrd/intel-contract": "^0.3.0",
47
47
  "@modelcontextprotocol/sdk": "^1.30.0",
48
48
  "ajv": "^8.20.0",
49
49
  "hono": "^4.12.32",