@anchrd/intel-api 0.6.6 → 0.7.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.
Files changed (51) hide show
  1. package/README.md +44 -3
  2. package/dist/adapters/cloudflare/cloudflare.js +50 -16
  3. package/dist/adapters/cloudflare/cloudflare.types.d.ts +11 -0
  4. package/dist/adapters/content/content.d.ts +1 -1
  5. package/dist/adapters/db/db-flows.js +161 -20
  6. package/dist/adapters/db/db-grants.d.ts +13 -2
  7. package/dist/adapters/db/db-grants.js +25 -8
  8. package/dist/adapters/db/db-indexing.d.ts +2 -2
  9. package/dist/adapters/db/db-indexing.js +26 -19
  10. package/dist/adapters/db/db.d.ts +3 -3
  11. package/dist/adapters/db/db.js +448 -119
  12. package/dist/adapters/gate-applications/gate-applications.d.ts +23 -0
  13. package/dist/adapters/gate-applications/gate-applications.js +66 -0
  14. package/dist/adapters/index-queue/index-queue.d.ts +1 -1
  15. package/dist/adapters/index-queue/index-queue.js +2 -2
  16. package/dist/adapters/semantic-index/semantic-index.types.d.ts +2 -2
  17. package/dist/agent-runtime/agent-runtime.d.ts +16 -0
  18. package/dist/agent-runtime/agent-runtime.js +76 -0
  19. package/dist/agent-runtime/agent-runtime.types.d.ts +57 -0
  20. package/dist/bundle/bundle.d.ts +4 -0
  21. package/dist/bundle/bundle.js +1035 -0
  22. package/dist/bundle/bundle.types.d.ts +33 -0
  23. package/dist/bundle/bundle.types.js +1 -0
  24. package/dist/cli/cli.js +10 -1
  25. package/dist/flows/flows.d.ts +8 -8
  26. package/dist/flows/flows.js +158 -42
  27. package/dist/flows/flows.types.d.ts +40 -7
  28. package/dist/http/http.d.ts +1 -0
  29. package/dist/http/http.js +329 -63
  30. package/dist/http/http.types.d.ts +6 -2
  31. package/dist/indexing/indexing.js +14 -2
  32. package/dist/indexing/indexing.types.d.ts +2 -2
  33. package/dist/intel/intel.js +12 -3
  34. package/dist/intel/intel.types.d.ts +6 -2
  35. package/dist/mcp/mcp.js +483 -124
  36. package/dist/mcp/mcp.types.d.ts +11 -2
  37. package/dist/nodes/nodes.d.ts +2 -0
  38. package/dist/nodes/nodes.js +1337 -0
  39. package/dist/nodes/nodes.types.d.ts +314 -0
  40. package/dist/nodes/nodes.types.js +1 -0
  41. package/migrations/0011_one_name_for_the_tree.sql +53 -0
  42. package/migrations/0012_table_snapshots.sql +29 -0
  43. package/migrations/0013_agents_in_the_tree.sql +76 -0
  44. package/migrations/0014_agent_applications.sql +25 -0
  45. package/package.json +3 -2
  46. package/dist/knowledge/knowledge.d.ts +0 -2
  47. package/dist/knowledge/knowledge.js +0 -761
  48. package/dist/knowledge/knowledge.types.d.ts +0 -198
  49. /package/dist/{knowledge/knowledge.types.js → agent-runtime/agent-runtime.types.js} +0 -0
  50. /package/dist/{knowledge → nodes}/document-links/document-links.d.ts +0 -0
  51. /package/dist/{knowledge → nodes}/document-links/document-links.js +0 -0
@@ -1,4 +1,6 @@
1
- import { nodeVerbBindings, nodeVerbQuery, subtreeBindings, subtreeCte } from "./db-grants.js";
1
+ import { descendantsCte, nodeVerbBindings, nodeVerbQuery, subtreeBindings, subtreeCte, } from "./db-grants.js";
2
+ const versionColumns = `id, node_id, sequence, content_key, media_type, content_hash,
3
+ size, segment, created_by, created_at`;
2
4
  const grantColumns = `id, node_id, principal_type, principal_id, verb, expires_at,
3
5
  created_by, created_at`;
4
6
  const linkColumns = `link.id, link.source_node_id, link.target_node_id, link.relation,
@@ -28,6 +30,7 @@ function mapVersion(row) {
28
30
  mediaType: row.media_type,
29
31
  contentHash: row.content_hash,
30
32
  size: row.size,
33
+ segment: row.segment,
31
34
  createdBy: row.created_by,
32
35
  createdAt: row.created_at,
33
36
  };
@@ -45,7 +48,7 @@ function mapGrant(row) {
45
48
  principal = { type: "organization" };
46
49
  break;
47
50
  default:
48
- throw new Error("Unsupported knowledge grant principal type");
51
+ throw new Error("Unsupported node grant principal type");
49
52
  }
50
53
  return {
51
54
  id: row.id,
@@ -76,11 +79,24 @@ function toFtsQuery(query) {
76
79
  .map((token) => `"${token.replaceAll('"', '""')}"`)
77
80
  .join(" AND ");
78
81
  }
79
- export function createKnowledgeRepository(deps) {
82
+ export function createNodeRepository(deps) {
80
83
  // Reading is one verb among four; the tree walk itself lives in db-grants.ts so Flows asks the
81
84
  // same question of the same table.
82
85
  const visibleCte = subtreeCte;
83
86
  const readBindings = (actor) => subtreeBindings(actor, "read", deps.now().toISOString());
87
+ /**
88
+ * The optional folder cut a search carries (#126), in the three places one statement needs it:
89
+ * the extra CTE, the extra join, and the one binding between the actor's and the query's. All
90
+ * three are absent together when there is no scope, so an unscoped search runs the exact
91
+ * statement it ran before.
92
+ *
93
+ * ⚠️ The join is added *beside* `allowed`, never instead of it. `descendants` is seeded from a
94
+ * caller-supplied id and knows nothing about grants; on its own it would answer "everything under
95
+ * this folder" to anyone who names the folder.
96
+ */
97
+ const scopeCte = (scopeId) => scopeId === undefined ? "" : `,\n${descendantsCte}`;
98
+ const scopeJoin = (scopeId) => scopeId === undefined ? "" : "\n JOIN descendants ON descendants.id = n.id";
99
+ const scopeBindings = (scopeId) => (scopeId === undefined ? [] : [scopeId]);
84
100
  /**
85
101
  * One statement for the level the tree reads and for the bounded read the relation graph makes,
86
102
  * so the visibility predicate cannot drift between them.
@@ -94,19 +110,94 @@ export function createKnowledgeRepository(deps) {
94
110
  -- level down. A second rule written here would drift from the one above it, and the drift
95
111
  -- would show as a chevron that opens onto nothing.
96
112
  EXISTS (
97
- SELECT 1 FROM knowledge_nodes child
113
+ SELECT 1 FROM nodes child
98
114
  JOIN allowed AS allowed_child ON allowed_child.id = child.id
99
115
  WHERE child.parent_id = n.id AND child.archived_at IS NULL
100
116
  ) AS has_children
101
- FROM knowledge_nodes n
117
+ FROM nodes n
102
118
  JOIN allowed ON allowed.id = n.id
103
119
  WHERE n.parent_id IS ? AND (? = 1 OR n.archived_at IS NULL)
104
120
  ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END, lower(n.title), n.id${bounded ? " LIMIT ?" : ""}`;
121
+ /**
122
+ * Everything archived that this actor may see, wherever it sits (#113).
123
+ *
124
+ * ⚠️ No `parent_id` at all, and that is the point rather than an omission: somebody looking for
125
+ * what they archived does not know which folder it was in. The visibility predicate is the same
126
+ * `allowed` set every other read uses, so the archive shows no more than the tree would have.
127
+ *
128
+ * `has_children` is answered as 0 without asking: an archived row is not expandable — its
129
+ * children are reached by restoring it, not by opening it here.
130
+ */
131
+ const archivedEverywhere = `${visibleCte}
132
+ SELECT ${nodeColumns}, 0 AS has_children
133
+ FROM nodes n
134
+ JOIN allowed ON allowed.id = n.id
135
+ WHERE n.archived_at IS NOT NULL
136
+ ORDER BY n.archived_at DESC, lower(n.title), n.id`;
137
+ /**
138
+ * The agents this actor may see, over the whole tree or under one folder (#139).
139
+ *
140
+ * ⚠️ The same `allowed` set every other read joins against, not a second predicate written for
141
+ * agents. An agent is a node, and the moment its list answers from its own rule it can answer
142
+ * differently from `list` about the very same row.
143
+ *
144
+ * Three scopes, matching the flow list: no clause at all for the whole tree, `IS NULL` for the
145
+ * root level, and one folder by ID.
146
+ */
147
+ const visibleAgents = (clause, includeArchived) => `${visibleCte}
148
+ SELECT ${nodeColumns}
149
+ FROM nodes n
150
+ JOIN allowed ON allowed.id = n.id
151
+ WHERE n.kind = 'agent' ${includeArchived ? "" : "AND n.archived_at IS NULL"} ${clause}
152
+ ORDER BY lower(n.title), n.id`;
153
+ const agentScopeOf = (parentId) => parentId === undefined
154
+ ? { clause: "", bindings: [] }
155
+ : parentId === null
156
+ ? { clause: "AND n.parent_id IS NULL", bindings: [] }
157
+ : { clause: "AND n.parent_id = ?", bindings: [parentId] };
105
158
  return {
159
+ async listVisibleAgents(actor, input) {
160
+ const scope = agentScopeOf(input.parentId);
161
+ const result = await deps.db
162
+ .prepare(visibleAgents(scope.clause, input.includeArchived === true))
163
+ .bind(...readBindings(actor), ...scope.bindings)
164
+ .all();
165
+ return (result.results ?? []).map(mapNode);
166
+ },
167
+ /**
168
+ * The R2 keys of the current definitions of every agent in this folder's subtree that this
169
+ * actor may see — the input to the share warning (#139, ADR-0005 §2).
170
+ *
171
+ * ⚠️ Keys, never bodies and never titles. Whether a referenced node may be NAMED to whoever is
172
+ * sharing is the tree's question and is asked afterwards through `getVisible`, exactly as the
173
+ * flow side does it. Answering it here would be the second rule that #17 and #19 got wrong.
174
+ *
175
+ * ⚠️ Agents this actor may not see contribute nothing, not even a count. Their references would
176
+ * otherwise be attributed to a folder this actor administers, which discloses that the agent is
177
+ * there at all.
178
+ */
179
+ async listVisibleAgentDefinitionKeys(actor, folderId) {
180
+ const result = await deps.db
181
+ .prepare(`${visibleCte},
182
+ scope(id) AS (
183
+ SELECT id FROM nodes WHERE id = ?
184
+ UNION
185
+ SELECT child.id FROM nodes child JOIN scope ON child.parent_id = scope.id
186
+ )
187
+ SELECT version.content_key AS content_key
188
+ FROM nodes n
189
+ JOIN allowed ON allowed.id = n.id
190
+ JOIN node_versions version ON version.id = n.current_version_id
191
+ WHERE n.kind = 'agent' AND n.archived_at IS NULL AND n.id IN (SELECT id FROM scope)
192
+ ORDER BY n.id`)
193
+ .bind(...readBindings(actor), folderId)
194
+ .all();
195
+ return (result.results ?? []).map((row) => row.content_key);
196
+ },
106
197
  async listVisible(actor, input) {
107
198
  const result = await deps.db
108
- .prepare(visibleChildren(false))
109
- .bind(...readBindings(actor), input.parentId, input.includeArchived ? 1 : 0)
199
+ .prepare(input.archivedOnly ? archivedEverywhere : visibleChildren(false))
200
+ .bind(...readBindings(actor), ...(input.archivedOnly ? [] : [input.parentId, input.includeArchived ? 1 : 0]))
110
201
  .all();
111
202
  const rows = result.results ?? [];
112
203
  return {
@@ -126,13 +217,168 @@ export function createKnowledgeRepository(deps) {
126
217
  const row = await deps.db
127
218
  .prepare(`${visibleCte}
128
219
  SELECT ${nodeColumns}
129
- FROM knowledge_nodes n
220
+ FROM nodes n
130
221
  JOIN allowed ON allowed.id = n.id
131
222
  WHERE n.id = ?`)
132
223
  .bind(...readBindings(actor), nodeId)
133
224
  .first();
134
225
  return row ? mapNode(row) : null;
135
226
  },
227
+ /**
228
+ * The readable subtree as one statement (#136): the `allowed` walk says what the actor may
229
+ * read, and `walk` says what hangs together under the chosen root. Both joins apply at every
230
+ * step, so the walk neither descends into an archived node nor through an unreadable one — a
231
+ * child the actor may read under a folder they may not stays out, because the bundle would have
232
+ * no path to file it under.
233
+ *
234
+ * ⚠️ `UNION`, never `UNION ALL`, for the reason `nodeVerbQuery` gives: a cycle in `parent_id`
235
+ * can reach the table and this walks downwards into it.
236
+ */
237
+ async listVisibleSubtree(actor, rootId) {
238
+ const seed = rootId === null
239
+ ? // "Everything this actor may read": the top of every readable region, not only the top
240
+ // of the tree. A reader who holds a grant on one nested folder has that folder as the
241
+ // top of what they see, and their whole-installation export starts there.
242
+ "(seed.parent_id IS NULL OR seed.parent_id NOT IN (SELECT id FROM allowed))"
243
+ : // The root node itself is the seed; whether it is a folder is the service's question.
244
+ "seed.id = ?";
245
+ const result = await deps.db
246
+ .prepare(`${visibleCte},
247
+ walk(id) AS (
248
+ SELECT seed.id FROM nodes seed
249
+ JOIN allowed ON allowed.id = seed.id
250
+ WHERE ${seed} AND seed.archived_at IS NULL
251
+ UNION
252
+ SELECT child.id FROM nodes child
253
+ JOIN walk ON child.parent_id = walk.id
254
+ JOIN allowed AS allowed_child ON allowed_child.id = child.id
255
+ WHERE child.archived_at IS NULL
256
+ )
257
+ SELECT ${nodeColumns},
258
+ version.id AS version_id, version.media_type, version.content_key
259
+ FROM nodes n
260
+ JOIN walk ON walk.id = n.id
261
+ LEFT JOIN node_versions version ON version.id = n.current_version_id`)
262
+ .bind(...readBindings(actor), ...(rootId === null ? [] : [rootId]))
263
+ .all();
264
+ return (result.results ?? []).map((row) => ({
265
+ node: mapNode(row),
266
+ version: row.version_id === null || row.media_type === null || row.content_key === null
267
+ ? null
268
+ : { id: row.version_id, mediaType: row.media_type, contentKey: row.content_key },
269
+ }));
270
+ },
271
+ /**
272
+ * One bundle import as one transaction (#137). Every table takes its rows from a single JSON
273
+ * parameter through `json_each`, so the statement count stays constant however large the
274
+ * bundle is — the same reason `idList` exists in db-flows — and `db.batch` makes the whole
275
+ * import land or refuse as one.
276
+ *
277
+ * ⚠️ `nodes` arrive parents before children: `parent_id REFERENCES nodes(id)` is checked as
278
+ * each row lands, and a child before its parent would abort the batch that is otherwise valid.
279
+ * The service owns that order; this only writes what it is handed.
280
+ *
281
+ * `context_policy` is the dead column migration 0009 explains; 'relevant' is the fixed value
282
+ * `insertNode` writes for the same reason.
283
+ */
284
+ async importTree(input) {
285
+ const statements = [
286
+ deps.db
287
+ .prepare(`INSERT INTO nodes (
288
+ id, parent_id, kind, title, description, context_policy, owner_id,
289
+ current_version_id, created_at, updated_at, archived_at
290
+ ) SELECT
291
+ json_extract(value, '$.id'), json_extract(value, '$.parentId'),
292
+ json_extract(value, '$.kind'), json_extract(value, '$.title'),
293
+ json_extract(value, '$.description'), 'relevant',
294
+ json_extract(value, '$.ownerId'), json_extract(value, '$.currentVersionId'),
295
+ json_extract(value, '$.createdAt'), json_extract(value, '$.updatedAt'), NULL
296
+ FROM json_each(?)`)
297
+ .bind(JSON.stringify(input.nodes)),
298
+ deps.db
299
+ .prepare(`INSERT INTO node_versions (
300
+ id, node_id, sequence, content_key, media_type, content_hash, size, segment,
301
+ created_by, created_at
302
+ ) SELECT
303
+ json_extract(value, '$.id'), json_extract(value, '$.nodeId'),
304
+ json_extract(value, '$.sequence'), json_extract(value, '$.contentKey'),
305
+ json_extract(value, '$.mediaType'), json_extract(value, '$.contentHash'),
306
+ json_extract(value, '$.size'), json_extract(value, '$.segment'),
307
+ json_extract(value, '$.createdBy'), json_extract(value, '$.createdAt')
308
+ FROM json_each(?)`)
309
+ .bind(JSON.stringify(input.versions)),
310
+ deps.db
311
+ .prepare(`INSERT INTO node_links (
312
+ id, source_node_id, target_node_id, relation, origin, label, created_by, created_at
313
+ ) SELECT
314
+ json_extract(value, '$.id'), json_extract(value, '$.sourceNodeId'),
315
+ json_extract(value, '$.targetNodeId'), 'references', 'text', NULL, ?, ?
316
+ FROM json_each(?)`)
317
+ .bind(input.actorId, input.occurredAt, JSON.stringify(input.links)),
318
+ deps.db
319
+ .prepare(`INSERT INTO flows (
320
+ id, parent_id, title, description, owner_id, current_version_id,
321
+ published_version_id, created_at, updated_at, archived_at
322
+ ) SELECT
323
+ json_extract(value, '$.id'), json_extract(value, '$.parentId'),
324
+ json_extract(value, '$.title'), json_extract(value, '$.description'),
325
+ json_extract(value, '$.ownerId'), json_extract(value, '$.currentVersionId'),
326
+ json_extract(value, '$.publishedVersionId'), json_extract(value, '$.createdAt'),
327
+ json_extract(value, '$.updatedAt'), NULL
328
+ FROM json_each(?)`)
329
+ .bind(JSON.stringify(input.flows)),
330
+ deps.db
331
+ .prepare(`INSERT INTO flow_versions (
332
+ id, flow_id, sequence, graph_json, created_by, created_at
333
+ ) SELECT
334
+ json_extract(value, '$.id'), json_extract(value, '$.flowId'),
335
+ -- json_extract of an object answers with its JSON text, which is exactly what the
336
+ -- graph_json column stores.
337
+ json_extract(value, '$.sequence'), json_extract(value, '$.graph'),
338
+ json_extract(value, '$.createdBy'), json_extract(value, '$.createdAt')
339
+ FROM json_each(?)`)
340
+ .bind(JSON.stringify(input.flowVersions)),
341
+ // ⚠️ The idempotency row stores the AUDIT id, unlike its siblings that store the resource:
342
+ // one import creates many resources, and the collective audit event is where the summary a
343
+ // replay answers with is kept.
344
+ deps.db
345
+ .prepare(`INSERT INTO idempotency_keys (
346
+ actor_id, operation, idempotency_key, resource_id, created_at
347
+ ) VALUES (?, 'node.import', ?, ?, ?)`)
348
+ .bind(input.actorId, input.idempotencyKey, input.auditId, input.occurredAt),
349
+ deps.db
350
+ .prepare(`INSERT INTO audit_events (
351
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
352
+ ) VALUES (?, ?, 'node.import', 'node', ?, ?, ?)`)
353
+ .bind(input.auditId, input.actorId, input.auditResourceId, JSON.stringify(input.metadata), input.occurredAt),
354
+ ];
355
+ try {
356
+ await deps.db.batch(statements);
357
+ return "created";
358
+ }
359
+ catch (error) {
360
+ const replayed = await this.findImportReplay(input.actorId, input.idempotencyKey);
361
+ if (replayed !== null)
362
+ return "replayed";
363
+ throw error;
364
+ }
365
+ },
366
+ async findImportReplay(actorId, idempotencyKey) {
367
+ const row = await deps.db
368
+ .prepare(`SELECT audit.metadata_json AS metadata_json
369
+ FROM idempotency_keys keys
370
+ JOIN audit_events audit ON audit.id = keys.resource_id
371
+ WHERE keys.actor_id = ? AND keys.operation = 'node.import'
372
+ AND keys.idempotency_key = ?`)
373
+ .bind(actorId, idempotencyKey)
374
+ .first();
375
+ if (!row)
376
+ return null;
377
+ const parsed = JSON.parse(row.metadata_json);
378
+ return typeof parsed === "object" && parsed !== null
379
+ ? parsed
380
+ : null;
381
+ },
136
382
  async can(actor, nodeId, verb) {
137
383
  const row = await deps.db
138
384
  .prepare(nodeVerbQuery)
@@ -151,15 +397,26 @@ export function createKnowledgeRepository(deps) {
151
397
  async findIdempotentRevocation(actorId, idempotencyKey) {
152
398
  const row = await deps.db
153
399
  .prepare(`SELECT resource_id FROM idempotency_keys
154
- WHERE actor_id = ? AND operation = 'knowledge.revoke' AND idempotency_key = ?`)
400
+ WHERE actor_id = ? AND operation = 'node.revoke' AND idempotency_key = ?`)
155
401
  .bind(actorId, idempotencyKey)
156
402
  .first();
157
403
  if (!row)
158
404
  return null;
159
405
  return row.resource_id.startsWith("1:");
160
406
  },
407
+ // ⚠️ Its own statement rather than a column on the node read: four of the five kinds can never
408
+ // have an Application, and the two joins every node read already carries are not going to grow
409
+ // a third for a question only agents ask (#182).
410
+ async agentApplicationId(nodeId) {
411
+ const row = await deps.db
412
+ .prepare("SELECT application_id FROM agent_applications WHERE node_id = ?")
413
+ .bind(nodeId)
414
+ .first();
415
+ return row?.application_id ?? null;
416
+ },
161
417
  async insertNode(input) {
162
418
  const node = input.node;
419
+ const application = input.application;
163
420
  try {
164
421
  await deps.db.batch([
165
422
  deps.db
@@ -167,7 +424,7 @@ export function createKnowledgeRepository(deps) {
167
424
  // without a DEFAULT and D1 will not let it be dropped — migration 0009 carries the
168
425
  // reason. The fixed value is the price; nothing reads it, and the contract no longer
169
426
  // knows the field. When the column goes (anchrd/intel#86), this line goes with it.
170
- .prepare(`INSERT INTO knowledge_nodes (
427
+ .prepare(`INSERT INTO nodes (
171
428
  id, parent_id, kind, title, description, context_policy, owner_id,
172
429
  current_version_id, created_at, updated_at, archived_at
173
430
  ) VALUES (?, ?, ?, ?, ?, 'relevant', ?, ?, ?, ?, ?)`)
@@ -175,26 +432,42 @@ export function createKnowledgeRepository(deps) {
175
432
  deps.db
176
433
  .prepare(`INSERT INTO idempotency_keys (
177
434
  actor_id, operation, idempotency_key, resource_id, created_at
178
- ) VALUES (?, 'knowledge.create', ?, ?, ?)`)
435
+ ) VALUES (?, 'node.create', ?, ?, ?)`)
179
436
  .bind(input.actorId, input.idempotencyKey, node.id, node.createdAt),
180
437
  deps.db
181
438
  .prepare(`INSERT INTO audit_events (
182
439
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
183
- ) VALUES (?, ?, 'knowledge.create', 'knowledge', ?, ?, ?)`)
184
- .bind(input.auditId, input.actorId, node.id, JSON.stringify({ kind: node.kind, parentId: node.parentId }), node.createdAt),
440
+ ) VALUES (?, ?, 'node.create', 'node', ?, ?, ?)`)
441
+ .bind(input.auditId, input.actorId, node.id,
442
+ // ⚠️ The Application ID is deliberately NOT in this metadata, and the key is not
443
+ // within reach of it at all. An audit event is read back by people and by the
444
+ // reindexer; what belongs in it is that an agent was created, not the name of the
445
+ // principal behind it — the mapping row below is where that is kept, once (D27).
446
+ JSON.stringify({ kind: node.kind, parentId: node.parentId }), node.createdAt),
447
+ // Last in the batch, and in the SAME batch: D1 runs a batch as one transaction, so the
448
+ // mapping cannot end up without its node or the node without its mapping. An agent whose
449
+ // principal is recorded nowhere is one that archiving would quietly fail to switch off.
450
+ ...(application
451
+ ? [
452
+ deps.db
453
+ .prepare(`INSERT INTO agent_applications (node_id, application_id, created_at)
454
+ VALUES (?, ?, ?)`)
455
+ .bind(node.id, application.id, node.createdAt),
456
+ ]
457
+ : []),
185
458
  ]);
186
459
  }
187
460
  catch (error) {
188
461
  const replayedKey = await deps.db
189
462
  .prepare(`SELECT resource_id FROM idempotency_keys
190
- WHERE actor_id = ? AND operation = 'knowledge.create' AND idempotency_key = ?`)
463
+ WHERE actor_id = ? AND operation = 'node.create' AND idempotency_key = ?`)
191
464
  .bind(input.actorId, input.idempotencyKey)
192
465
  .first();
193
466
  if (replayedKey) {
194
467
  const replayed = await deps.db
195
468
  .prepare(`${visibleCte}
196
469
  SELECT ${nodeColumns}
197
- FROM knowledge_nodes n
470
+ FROM nodes n
198
471
  JOIN allowed ON allowed.id = n.id
199
472
  WHERE n.id = ?`)
200
473
  .bind(...readBindings({ id: input.actorId, email: "" }), replayedKey.resource_id)
@@ -208,33 +481,38 @@ export function createKnowledgeRepository(deps) {
208
481
  },
209
482
  async getVersion(versionId) {
210
483
  const row = await deps.db
211
- .prepare(`SELECT id, node_id, sequence, content_key, media_type, content_hash,
212
- size, created_by, created_at FROM knowledge_versions WHERE id = ?`)
484
+ .prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
213
485
  .bind(versionId)
214
486
  .first();
215
487
  return row ? mapVersion(row) : null;
216
488
  },
489
+ // From the newest snapshot on (#135). `COALESCE(..., 0)` keeps a table from before snapshots
490
+ // readable in full: without any snapshot row every segment still counts.
217
491
  async listVersionContentKeys(nodeId) {
218
492
  const result = await deps.db
219
- .prepare(`SELECT content_key FROM knowledge_versions
220
- WHERE node_id = ? ORDER BY sequence`)
221
- .bind(nodeId)
493
+ .prepare(`SELECT content_key FROM node_versions
494
+ WHERE node_id = ? AND sequence >= COALESCE((
495
+ SELECT MAX(sequence) FROM node_versions
496
+ WHERE node_id = ? AND segment = 'snapshot'
497
+ ), 0)
498
+ ORDER BY sequence`)
499
+ .bind(nodeId, nodeId)
222
500
  .all();
223
501
  return (result.results ?? []).map((row) => row.content_key);
224
502
  },
225
- async firstVersionContentKey(nodeId) {
503
+ async tableHeaderContentKey(nodeId) {
226
504
  const row = await deps.db
227
- .prepare(`SELECT content_key FROM knowledge_versions
228
- WHERE node_id = ? ORDER BY sequence LIMIT 1`)
505
+ .prepare(`SELECT content_key FROM node_versions
506
+ WHERE node_id = ? AND segment = 'snapshot'
507
+ ORDER BY sequence DESC LIMIT 1`)
229
508
  .bind(nodeId)
230
509
  .first();
231
510
  return row?.content_key ?? null;
232
511
  },
233
512
  async listVersions(nodeId) {
234
513
  const result = await deps.db
235
- .prepare(`SELECT id, node_id, sequence, content_key, media_type, content_hash,
236
- size, created_by, created_at
237
- FROM knowledge_versions
514
+ .prepare(`SELECT ${versionColumns}
515
+ FROM node_versions
238
516
  WHERE node_id = ?
239
517
  ORDER BY sequence DESC`)
240
518
  .bind(nodeId)
@@ -247,13 +525,13 @@ export function createKnowledgeRepository(deps) {
247
525
  await deps.db.batch([
248
526
  deps.db
249
527
  .prepare(`WITH RECURSIVE descendants(id) AS (
250
- SELECT id FROM knowledge_nodes WHERE id = ?
528
+ SELECT id FROM nodes WHERE id = ?
251
529
  UNION ALL
252
530
  SELECT child.id
253
- FROM knowledge_nodes child
531
+ FROM nodes child
254
532
  JOIN descendants parent ON child.parent_id = parent.id
255
533
  )
256
- UPDATE knowledge_nodes
534
+ UPDATE nodes
257
535
  SET parent_id = ?, title = ?, description = ?, updated_at = ?
258
536
  WHERE id = ? AND updated_at = ?
259
537
  AND (? IS NULL OR ? NOT IN (SELECT id FROM descendants))`)
@@ -261,9 +539,9 @@ export function createKnowledgeRepository(deps) {
261
539
  deps.db
262
540
  .prepare(`INSERT INTO idempotency_keys (
263
541
  actor_id, operation, idempotency_key, resource_id, created_at
264
- ) SELECT ?, 'knowledge.update', ?, ?, ?
542
+ ) SELECT ?, 'node.update', ?, ?, ?
265
543
  WHERE EXISTS (
266
- SELECT 1 FROM knowledge_nodes
544
+ SELECT 1 FROM nodes
267
545
  WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ?
268
546
  AND updated_at = ?
269
547
  )`)
@@ -271,10 +549,10 @@ export function createKnowledgeRepository(deps) {
271
549
  deps.db
272
550
  .prepare(`INSERT INTO audit_events (
273
551
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
274
- ) SELECT ?, ?, 'knowledge.update', 'knowledge', ?, ?, ?
552
+ ) SELECT ?, ?, 'node.update', 'node', ?, ?, ?
275
553
  WHERE EXISTS (
276
554
  SELECT 1 FROM idempotency_keys
277
- WHERE actor_id = ? AND operation = 'knowledge.update'
555
+ WHERE actor_id = ? AND operation = 'node.update'
278
556
  AND idempotency_key = ? AND resource_id = ?
279
557
  )`)
280
558
  .bind(input.auditId, input.actorId, node.id, JSON.stringify({
@@ -286,7 +564,7 @@ export function createKnowledgeRepository(deps) {
286
564
  catch (error) {
287
565
  const replayed = await deps.db
288
566
  .prepare(`SELECT resource_id FROM idempotency_keys
289
- WHERE actor_id = ? AND operation = 'knowledge.update' AND idempotency_key = ?`)
567
+ WHERE actor_id = ? AND operation = 'node.update' AND idempotency_key = ?`)
290
568
  .bind(input.actorId, input.idempotencyKey)
291
569
  .first();
292
570
  if (!replayed)
@@ -294,12 +572,12 @@ export function createKnowledgeRepository(deps) {
294
572
  }
295
573
  const replayed = await deps.db
296
574
  .prepare(`SELECT resource_id FROM idempotency_keys
297
- WHERE actor_id = ? AND operation = 'knowledge.update' AND idempotency_key = ?`)
575
+ WHERE actor_id = ? AND operation = 'node.update' AND idempotency_key = ?`)
298
576
  .bind(input.actorId, input.idempotencyKey)
299
577
  .first();
300
578
  if (replayed) {
301
579
  const row = await deps.db
302
- .prepare(`SELECT ${nodeColumns} FROM knowledge_nodes n WHERE n.id = ?`)
580
+ .prepare(`SELECT ${nodeColumns} FROM nodes n WHERE n.id = ?`)
303
581
  .bind(replayed.resource_id)
304
582
  .first();
305
583
  if (row)
@@ -308,10 +586,10 @@ export function createKnowledgeRepository(deps) {
308
586
  if (node.parentId !== null) {
309
587
  const cycle = await deps.db
310
588
  .prepare(`WITH RECURSIVE descendants(id) AS (
311
- SELECT id FROM knowledge_nodes WHERE id = ?
589
+ SELECT id FROM nodes WHERE id = ?
312
590
  UNION ALL
313
591
  SELECT child.id
314
- FROM knowledge_nodes child
592
+ FROM nodes child
315
593
  JOIN descendants parent ON child.parent_id = parent.id
316
594
  )
317
595
  SELECT 1 AS found FROM descendants WHERE id = ? LIMIT 1`)
@@ -326,26 +604,26 @@ export function createKnowledgeRepository(deps) {
326
604
  try {
327
605
  await deps.db.batch([
328
606
  deps.db
329
- .prepare(`UPDATE knowledge_nodes
607
+ .prepare(`UPDATE nodes
330
608
  SET archived_at = ?, updated_at = ?
331
609
  WHERE id = ? AND updated_at = ?`)
332
610
  .bind(input.archivedAt, input.updatedAt, input.nodeId, input.baseUpdatedAt),
333
611
  deps.db
334
612
  .prepare(`INSERT INTO idempotency_keys (
335
613
  actor_id, operation, idempotency_key, resource_id, created_at
336
- ) SELECT ?, 'knowledge.archive', ?, ?, ?
614
+ ) SELECT ?, 'node.archive', ?, ?, ?
337
615
  WHERE EXISTS (
338
- SELECT 1 FROM knowledge_nodes
616
+ SELECT 1 FROM nodes
339
617
  WHERE id = ? AND archived_at IS ? AND updated_at = ?
340
618
  )`)
341
619
  .bind(input.actorId, input.idempotencyKey, input.nodeId, input.updatedAt, input.nodeId, input.archivedAt, input.updatedAt),
342
620
  deps.db
343
621
  .prepare(`INSERT INTO audit_events (
344
622
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
345
- ) SELECT ?, ?, 'knowledge.archive', 'knowledge', ?, ?, ?
623
+ ) SELECT ?, ?, 'node.archive', 'node', ?, ?, ?
346
624
  WHERE EXISTS (
347
625
  SELECT 1 FROM idempotency_keys
348
- WHERE actor_id = ? AND operation = 'knowledge.archive'
626
+ WHERE actor_id = ? AND operation = 'node.archive'
349
627
  AND idempotency_key = ? AND resource_id = ?
350
628
  )`)
351
629
  .bind(input.auditId, input.actorId, input.nodeId, JSON.stringify({ archived: input.archivedAt !== null }), input.updatedAt, input.actorId, input.idempotencyKey, input.nodeId),
@@ -354,7 +632,7 @@ export function createKnowledgeRepository(deps) {
354
632
  catch (error) {
355
633
  const replayed = await deps.db
356
634
  .prepare(`SELECT resource_id FROM idempotency_keys
357
- WHERE actor_id = ? AND operation = 'knowledge.archive' AND idempotency_key = ?`)
635
+ WHERE actor_id = ? AND operation = 'node.archive' AND idempotency_key = ?`)
358
636
  .bind(input.actorId, input.idempotencyKey)
359
637
  .first();
360
638
  if (!replayed)
@@ -362,13 +640,13 @@ export function createKnowledgeRepository(deps) {
362
640
  }
363
641
  const replayed = await deps.db
364
642
  .prepare(`SELECT resource_id FROM idempotency_keys
365
- WHERE actor_id = ? AND operation = 'knowledge.archive' AND idempotency_key = ?`)
643
+ WHERE actor_id = ? AND operation = 'node.archive' AND idempotency_key = ?`)
366
644
  .bind(input.actorId, input.idempotencyKey)
367
645
  .first();
368
646
  if (!replayed)
369
647
  return "conflict";
370
648
  const row = await deps.db
371
- .prepare(`SELECT ${nodeColumns} FROM knowledge_nodes n WHERE n.id = ?`)
649
+ .prepare(`SELECT ${nodeColumns} FROM nodes n WHERE n.id = ?`)
372
650
  .bind(replayed.resource_id)
373
651
  .first();
374
652
  return row ? mapNode(row) : "conflict";
@@ -377,38 +655,39 @@ export function createKnowledgeRepository(deps) {
377
655
  const version = input.version;
378
656
  await deps.db.batch([
379
657
  deps.db
380
- .prepare(`INSERT INTO knowledge_versions (
381
- id, node_id, sequence, content_key, media_type, content_hash, size, created_by, created_at
382
- ) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?
383
- FROM knowledge_nodes
658
+ .prepare(`INSERT INTO node_versions (
659
+ id, node_id, sequence, content_key, media_type, content_hash, size, segment,
660
+ created_by, created_at
661
+ ) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
662
+ FROM nodes
384
663
  WHERE id = ? AND current_version_id IS ?`)
385
- .bind(version.id, version.nodeId, version.sequence, version.contentKey, version.mediaType, version.contentHash, version.size, version.createdBy, version.createdAt, version.nodeId, input.baseVersionId),
664
+ .bind(version.id, version.nodeId, version.sequence, version.contentKey, version.mediaType, version.contentHash, version.size, version.segment, version.createdBy, version.createdAt, version.nodeId, input.baseVersionId),
386
665
  deps.db
387
- .prepare(`UPDATE knowledge_nodes
666
+ .prepare(`UPDATE nodes
388
667
  SET current_version_id = ?, updated_at = ?
389
- WHERE id = ? AND EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
668
+ WHERE id = ? AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
390
669
  .bind(version.id, version.createdAt, version.nodeId, version.id),
391
670
  deps.db
392
671
  .prepare(`INSERT INTO idempotency_keys (
393
672
  actor_id, operation, idempotency_key, resource_id, created_at
394
- ) SELECT ?, 'knowledge.save', ?, ?, ?
395
- WHERE EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
673
+ ) SELECT ?, 'node.save', ?, ?, ?
674
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
396
675
  .bind(input.actorId, input.idempotencyKey, version.id, version.createdAt, version.id),
397
676
  deps.db
398
- .prepare(`INSERT INTO knowledge_index_state (
677
+ .prepare(`INSERT INTO node_index_state (
399
678
  version_id, status, attempt_count, last_error, updated_at
400
679
  ) SELECT ?, 'pending', 0, NULL, ?
401
- WHERE EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
680
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
402
681
  .bind(version.id, version.createdAt, version.id),
403
682
  deps.db
404
683
  .prepare(`INSERT INTO audit_events (
405
684
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
406
- ) SELECT ?, ?, 'knowledge.save', 'knowledge', ?, ?, ?
407
- WHERE EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
685
+ ) SELECT ?, ?, 'node.save', 'node', ?, ?, ?
686
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
408
687
  .bind(input.auditId, input.actorId, version.nodeId, JSON.stringify({ versionId: version.id, sequence: version.sequence }), version.createdAt, version.id),
409
688
  ]);
410
689
  const row = await deps.db
411
- .prepare("SELECT current_version_id FROM knowledge_nodes WHERE id = ?")
690
+ .prepare("SELECT current_version_id FROM nodes WHERE id = ?")
412
691
  .bind(version.nodeId)
413
692
  .first();
414
693
  return row?.current_version_id === version.id ? "saved" : "conflict";
@@ -427,51 +706,50 @@ export function createKnowledgeRepository(deps) {
427
706
  async appendTableVersion(input) {
428
707
  const version = input.version;
429
708
  const stored = async () => await deps.db
430
- .prepare(`SELECT id, node_id, sequence, content_key, media_type, content_hash,
431
- size, created_by, created_at FROM knowledge_versions WHERE id = ?`)
709
+ .prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
432
710
  .bind(version.id)
433
711
  .first();
434
712
  try {
435
713
  await deps.db.batch([
436
714
  deps.db
437
- .prepare(`INSERT INTO knowledge_versions (
438
- id, node_id, sequence, content_key, media_type, content_hash, size,
715
+ .prepare(`INSERT INTO node_versions (
716
+ id, node_id, sequence, content_key, media_type, content_hash, size, segment,
439
717
  created_by, created_at
440
- ) SELECT ?, ?, COALESCE(MAX(sequence), 0) + 1, ?, ?, ?, ?, ?, ?
441
- FROM knowledge_versions WHERE node_id = ?`)
442
- .bind(version.id, version.nodeId, version.contentKey, version.mediaType, version.contentHash, version.size, version.createdBy, version.createdAt, version.nodeId),
718
+ ) SELECT ?, ?, COALESCE(MAX(sequence), 0) + 1, ?, ?, ?, ?, ?, ?, ?
719
+ FROM node_versions WHERE node_id = ?`)
720
+ .bind(version.id, version.nodeId, version.contentKey, version.mediaType, version.contentHash, version.size, version.segment, version.createdBy, version.createdAt, version.nodeId),
443
721
  deps.db
444
- .prepare(`UPDATE knowledge_nodes
722
+ .prepare(`UPDATE nodes
445
723
  SET current_version_id = ?, updated_at = ?
446
724
  WHERE id = ?
447
- AND EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)
725
+ AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)
448
726
  AND (
449
727
  current_version_id IS NULL
450
728
  OR COALESCE((
451
- SELECT sequence FROM knowledge_versions
452
- WHERE id = knowledge_nodes.current_version_id
453
- ), 0) < (SELECT sequence FROM knowledge_versions WHERE id = ?)
729
+ SELECT sequence FROM node_versions
730
+ WHERE id = nodes.current_version_id
731
+ ), 0) < (SELECT sequence FROM node_versions WHERE id = ?)
454
732
  )`)
455
733
  .bind(version.id, version.createdAt, version.nodeId, version.id, version.id),
456
734
  deps.db
457
735
  .prepare(`INSERT INTO idempotency_keys (
458
736
  actor_id, operation, idempotency_key, resource_id, created_at
459
- ) SELECT ?, 'knowledge.append', ?, ?, ?
460
- WHERE EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
737
+ ) SELECT ?, 'node.append', ?, ?, ?
738
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
461
739
  .bind(input.actorId, input.idempotencyKey, version.id, version.createdAt, version.id),
462
740
  deps.db
463
- .prepare(`INSERT INTO knowledge_index_state (
741
+ .prepare(`INSERT INTO node_index_state (
464
742
  version_id, status, attempt_count, last_error, updated_at
465
743
  ) SELECT ?, 'pending', 0, NULL, ?
466
- WHERE EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
744
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
467
745
  .bind(version.id, version.createdAt, version.id),
468
746
  deps.db
469
747
  .prepare(`INSERT INTO audit_events (
470
748
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
471
- ) SELECT ?, ?, 'knowledge.append', 'knowledge', ?, json_object(
749
+ ) SELECT ?, ?, 'node.append', 'node', ?, json_object(
472
750
  'versionId', ?, 'size', ?
473
751
  ), ?
474
- WHERE EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
752
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
475
753
  .bind(input.auditId, input.actorId, version.nodeId, version.id, version.size, version.createdAt, version.id),
476
754
  ]);
477
755
  }
@@ -480,13 +758,12 @@ export function createKnowledgeRepository(deps) {
480
758
  // answers with it rather than with a failure the caller cannot act on.
481
759
  const replayed = await deps.db
482
760
  .prepare(`SELECT resource_id FROM idempotency_keys
483
- WHERE actor_id = ? AND operation = 'knowledge.append' AND idempotency_key = ?`)
761
+ WHERE actor_id = ? AND operation = 'node.append' AND idempotency_key = ?`)
484
762
  .bind(input.actorId, input.idempotencyKey)
485
763
  .first();
486
764
  const row = replayed
487
765
  ? await deps.db
488
- .prepare(`SELECT id, node_id, sequence, content_key, media_type, content_hash,
489
- size, created_by, created_at FROM knowledge_versions WHERE id = ?`)
766
+ .prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
490
767
  .bind(replayed.resource_id)
491
768
  .first()
492
769
  : null;
@@ -496,9 +773,61 @@ export function createKnowledgeRepository(deps) {
496
773
  }
497
774
  const row = await stored();
498
775
  if (!row)
499
- throw new Error("Knowledge table append did not store a version");
776
+ throw new Error("Node table append did not store a version");
500
777
  return mapVersion(row);
501
778
  },
779
+ /**
780
+ * One snapshot of a table, written only against the state it replaces (#135).
781
+ *
782
+ * ⚠️ The shape of `appendVersion`, not of the append above it: the base check sits inside the
783
+ * INSERT's own WHERE, so a segment that landed between the service's read and this batch makes
784
+ * the INSERT store nothing and every guarded statement after it a no-op. The service reads the
785
+ * answer the same way `save` does — by asking whether the node now stands on the new version.
786
+ *
787
+ * The idempotency row carries the mutation's own operation, so a replayed update can never be
788
+ * confused with a delete that reused the key, and the audit trail names what was done.
789
+ */
790
+ async appendTableSnapshot(input) {
791
+ const version = input.version;
792
+ await deps.db.batch([
793
+ deps.db
794
+ .prepare(`INSERT INTO node_versions (
795
+ id, node_id, sequence, content_key, media_type, content_hash, size, segment,
796
+ created_by, created_at
797
+ ) SELECT ?, ?, ?, ?, ?, ?, ?, 'snapshot', ?, ?
798
+ FROM nodes
799
+ WHERE id = ? AND current_version_id IS ?`)
800
+ .bind(version.id, version.nodeId, version.sequence, version.contentKey, version.mediaType, version.contentHash, version.size, version.createdBy, version.createdAt, version.nodeId, input.baseVersionId),
801
+ deps.db
802
+ .prepare(`UPDATE nodes
803
+ SET current_version_id = ?, updated_at = ?
804
+ WHERE id = ? AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
805
+ .bind(version.id, version.createdAt, version.nodeId, version.id),
806
+ deps.db
807
+ .prepare(`INSERT INTO idempotency_keys (
808
+ actor_id, operation, idempotency_key, resource_id, created_at
809
+ ) SELECT ?, ?, ?, ?, ?
810
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
811
+ .bind(input.actorId, input.operation, input.idempotencyKey, version.id, version.createdAt, version.id),
812
+ deps.db
813
+ .prepare(`INSERT INTO node_index_state (
814
+ version_id, status, attempt_count, last_error, updated_at
815
+ ) SELECT ?, 'pending', 0, NULL, ?
816
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
817
+ .bind(version.id, version.createdAt, version.id),
818
+ deps.db
819
+ .prepare(`INSERT INTO audit_events (
820
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
821
+ ) SELECT ?, ?, ?, 'node', ?, ?, ?
822
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
823
+ .bind(input.auditId, input.actorId, input.operation, version.nodeId, JSON.stringify({ versionId: version.id, ...input.metadata }), version.createdAt, version.id),
824
+ ]);
825
+ const row = await deps.db
826
+ .prepare("SELECT current_version_id FROM nodes WHERE id = ?")
827
+ .bind(version.nodeId)
828
+ .first();
829
+ return row?.current_version_id === version.id ? "saved" : "conflict";
830
+ },
502
831
  async listGrants(resourceId) {
503
832
  const result = await deps.db
504
833
  .prepare(`SELECT ${grantColumns} FROM tree_grants
@@ -513,10 +842,10 @@ export function createKnowledgeRepository(deps) {
513
842
  async organizationExecuteReaches(nodeId, exceptGrantId) {
514
843
  const row = await deps.db
515
844
  .prepare(`WITH RECURSIVE ancestors(id, parent_id) AS (
516
- SELECT id, parent_id FROM knowledge_nodes WHERE id = ?
845
+ SELECT id, parent_id FROM nodes WHERE id = ?
517
846
  UNION
518
847
  SELECT parent.id, parent.parent_id
519
- FROM knowledge_nodes parent
848
+ FROM nodes parent
520
849
  JOIN ancestors child ON child.parent_id = parent.id
521
850
  )
522
851
  SELECT 1 AS reaches
@@ -535,7 +864,7 @@ export function createKnowledgeRepository(deps) {
535
864
  const result = await deps.db
536
865
  .prepare(`${visibleCte}
537
866
  SELECT ${linkColumns}
538
- FROM knowledge_links link
867
+ FROM node_links link
539
868
  JOIN allowed source_access ON source_access.id = link.source_node_id
540
869
  JOIN allowed target_access ON target_access.id = link.target_node_id
541
870
  WHERE link.source_node_id = ? OR link.target_node_id = ?
@@ -562,7 +891,7 @@ export function createKnowledgeRepository(deps) {
562
891
  const result = await deps.db
563
892
  .prepare(`${visibleCte}
564
893
  SELECT n.id, n.title
565
- FROM knowledge_nodes n
894
+ FROM nodes n
566
895
  JOIN allowed ON allowed.id = n.id
567
896
  WHERE n.id IN (${placeholders}) AND n.archived_at IS NULL`)
568
897
  .bind(...readBindings(actor), ...ids)
@@ -584,10 +913,10 @@ export function createKnowledgeRepository(deps) {
584
913
  async replaceTextLinks(input) {
585
914
  await deps.db.batch([
586
915
  deps.db
587
- .prepare("DELETE FROM knowledge_links WHERE source_node_id = ? AND origin = 'text'")
916
+ .prepare("DELETE FROM node_links WHERE source_node_id = ? AND origin = 'text'")
588
917
  .bind(input.sourceNodeId),
589
918
  ...input.links.map((link) => deps.db
590
- .prepare(`INSERT INTO knowledge_links (
919
+ .prepare(`INSERT INTO node_links (
591
920
  id, source_node_id, target_node_id, relation, origin, label, created_by, created_at
592
921
  ) VALUES (?, ?, ?, 'references', 'text', NULL, ?, ?)
593
922
  ON CONFLICT (source_node_id, target_node_id, relation) DO NOTHING`)
@@ -595,7 +924,7 @@ export function createKnowledgeRepository(deps) {
595
924
  deps.db
596
925
  .prepare(`INSERT INTO audit_events (
597
926
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
598
- ) VALUES (?, ?, 'knowledge.link', 'knowledge', ?, ?, ?)`)
927
+ ) VALUES (?, ?, 'node.link', 'node', ?, ?, ?)`)
599
928
  .bind(input.auditId, input.actorId, input.sourceNodeId, JSON.stringify({ targetNodeIds: input.links.map((link) => link.targetNodeId) }), input.occurredAt),
600
929
  ]);
601
930
  },
@@ -603,7 +932,7 @@ export function createKnowledgeRepository(deps) {
603
932
  const nodeResult = await deps.db
604
933
  .prepare(`${visibleCte}
605
934
  SELECT ${nodeColumns}
606
- FROM knowledge_nodes n
935
+ FROM nodes n
607
936
  JOIN allowed ON allowed.id = n.id
608
937
  WHERE n.archived_at IS NULL
609
938
  ORDER BY lower(n.title), n.id
@@ -615,14 +944,14 @@ export function createKnowledgeRepository(deps) {
615
944
  return { nodes, links: [] };
616
945
  const linkResult = await deps.db
617
946
  .prepare(`${visibleCte}, graph_nodes(id) AS (
618
- SELECT n.id FROM knowledge_nodes n
947
+ SELECT n.id FROM nodes n
619
948
  JOIN allowed ON allowed.id = n.id
620
949
  WHERE n.archived_at IS NULL
621
950
  ORDER BY lower(n.title), n.id
622
951
  LIMIT ?
623
952
  )
624
953
  SELECT ${linkColumns}
625
- FROM knowledge_links link
954
+ FROM node_links link
626
955
  JOIN graph_nodes source_node ON source_node.id = link.source_node_id
627
956
  JOIN graph_nodes target_node ON target_node.id = link.target_node_id
628
957
  ORDER BY link.created_at, link.id`)
@@ -653,13 +982,13 @@ export function createKnowledgeRepository(deps) {
653
982
  deps.db
654
983
  .prepare(`INSERT INTO idempotency_keys (
655
984
  actor_id, operation, idempotency_key, resource_id, created_at
656
- ) SELECT ?, 'knowledge.share', ?, id, ? FROM tree_grants
985
+ ) SELECT ?, 'node.share', ?, id, ? FROM tree_grants
657
986
  WHERE node_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
658
987
  .bind(input.actorId, input.idempotencyKey, grant.createdAt, grant.resourceId, principalType, principalId, grant.verb),
659
988
  deps.db
660
989
  .prepare(`INSERT INTO audit_events (
661
990
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
662
- ) VALUES (?, ?, 'knowledge.share', 'knowledge', ?, ?, ?)`)
991
+ ) VALUES (?, ?, 'node.share', 'node', ?, ?, ?)`)
663
992
  .bind(input.auditId, input.actorId, grant.resourceId, JSON.stringify({ principalType, verb: grant.verb }), grant.createdAt),
664
993
  ]);
665
994
  const stored = await deps.db
@@ -668,13 +997,13 @@ export function createKnowledgeRepository(deps) {
668
997
  .bind(grant.resourceId, principalType, principalId, grant.verb)
669
998
  .first();
670
999
  if (!stored)
671
- throw new Error("Knowledge grant disappeared after upsert");
1000
+ throw new Error("Node grant disappeared after upsert");
672
1001
  return mapGrant(stored);
673
1002
  }
674
1003
  catch (error) {
675
1004
  const replayed = await deps.db
676
1005
  .prepare(`SELECT resource_id FROM idempotency_keys
677
- WHERE actor_id = ? AND operation = 'knowledge.share' AND idempotency_key = ?`)
1006
+ WHERE actor_id = ? AND operation = 'node.share' AND idempotency_key = ?`)
678
1007
  .bind(input.actorId, input.idempotencyKey)
679
1008
  .first();
680
1009
  if (replayed) {
@@ -694,7 +1023,7 @@ export function createKnowledgeRepository(deps) {
694
1023
  deps.db
695
1024
  .prepare(`INSERT INTO idempotency_keys (
696
1025
  actor_id, operation, idempotency_key, resource_id, created_at
697
- ) SELECT ?, 'knowledge.revoke', ?,
1026
+ ) SELECT ?, 'node.revoke', ?,
698
1027
  (CASE WHEN EXISTS (
699
1028
  SELECT 1 FROM tree_grants WHERE id = ? AND node_id = ?
700
1029
  ) THEN '1:' ELSE '0:' END) || ?, ?`)
@@ -705,13 +1034,13 @@ export function createKnowledgeRepository(deps) {
705
1034
  deps.db
706
1035
  .prepare(`INSERT INTO audit_events (
707
1036
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
708
- ) SELECT ?, ?, 'knowledge.revoke', 'knowledge', ?,
1037
+ ) SELECT ?, ?, 'node.revoke', 'node', ?,
709
1038
  json_object(
710
1039
  'grantId', ?,
711
1040
  'revoked', json(CASE WHEN substr(resource_id, 1, 2) = '1:' THEN 'true' ELSE 'false' END)
712
1041
  ), ?
713
1042
  FROM idempotency_keys
714
- WHERE actor_id = ? AND operation = 'knowledge.revoke' AND idempotency_key = ?`)
1043
+ WHERE actor_id = ? AND operation = 'node.revoke' AND idempotency_key = ?`)
715
1044
  .bind(input.auditId, input.actorId, input.resourceId, input.grantId, input.occurredAt, input.actorId, input.idempotencyKey),
716
1045
  ]);
717
1046
  }
@@ -728,33 +1057,33 @@ export function createKnowledgeRepository(deps) {
728
1057
  if (!query)
729
1058
  return [];
730
1059
  const result = await deps.db
731
- .prepare(`${visibleCte}
1060
+ .prepare(`${visibleCte}${scopeCte(input.scopeId)}
732
1061
  SELECT n.id AS node_id, v.id AS version_id, n.title,
733
- snippet(knowledge_fts, 3, '', '', ' … ', 32) AS passage,
734
- n.updated_at AS freshness, bm25(knowledge_fts, 0.0, 0.0, 4.0, 1.0) AS rank
735
- FROM knowledge_fts
736
- JOIN knowledge_nodes n ON n.id = knowledge_fts.node_id
737
- JOIN knowledge_versions v ON v.id = knowledge_fts.version_id
738
- JOIN allowed ON allowed.id = n.id
739
- WHERE knowledge_fts MATCH ?
1062
+ snippet(node_fts, 3, '', '', ' … ', 32) AS passage,
1063
+ n.updated_at AS freshness, bm25(node_fts, 0.0, 0.0, 4.0, 1.0) AS rank
1064
+ FROM node_fts
1065
+ JOIN nodes n ON n.id = node_fts.node_id
1066
+ JOIN node_versions v ON v.id = node_fts.version_id
1067
+ JOIN allowed ON allowed.id = n.id${scopeJoin(input.scopeId)}
1068
+ WHERE node_fts MATCH ?
740
1069
  AND n.current_version_id = v.id
741
1070
  AND n.archived_at IS NULL
742
1071
  ORDER BY rank, n.updated_at DESC
743
1072
  LIMIT ?`)
744
- .bind(...readBindings(actor), query, input.limit)
1073
+ .bind(...readBindings(actor), ...scopeBindings(input.scopeId), query, input.limit)
745
1074
  .all();
746
1075
  return (result.results ?? []).map((row) => ({
747
1076
  nodeId: row.node_id,
748
1077
  versionId: row.version_id,
749
1078
  title: row.title,
750
1079
  passage: row.passage,
751
- source: `intel://knowledge/${encodeURIComponent(row.node_id)}?version=${encodeURIComponent(row.version_id)}`,
1080
+ source: `intel://nodes/${encodeURIComponent(row.node_id)}?version=${encodeURIComponent(row.version_id)}`,
752
1081
  freshness: row.freshness,
753
1082
  score: 1 / (1 + Math.abs(row.rank)),
754
1083
  match: "lexical",
755
1084
  }));
756
1085
  },
757
- async hydrateVisibleCitations(actor, nodeIds) {
1086
+ async hydrateVisibleCitations(actor, nodeIds, scopeId) {
758
1087
  const uniqueIds = [...new Set(nodeIds)].slice(0, 100);
759
1088
  if (uniqueIds.length === 0)
760
1089
  return [];
@@ -763,16 +1092,16 @@ export function createKnowledgeRepository(deps) {
763
1092
  const ids = uniqueIds.slice(offset, offset + 96);
764
1093
  const placeholders = ids.map(() => "?").join(", ");
765
1094
  const result = await deps.db
766
- .prepare(`${visibleCte}
1095
+ .prepare(`${visibleCte}${scopeCte(scopeId)}
767
1096
  SELECT n.id AS node_id, v.id AS version_id, n.title,
768
- substr(knowledge_fts.content, 1, 480) AS passage,
1097
+ substr(node_fts.content, 1, 480) AS passage,
769
1098
  n.updated_at AS freshness
770
- FROM knowledge_nodes n
771
- JOIN knowledge_versions v ON v.id = n.current_version_id
772
- JOIN knowledge_fts ON knowledge_fts.version_id = v.id
773
- JOIN allowed ON allowed.id = n.id
1099
+ FROM nodes n
1100
+ JOIN node_versions v ON v.id = n.current_version_id
1101
+ JOIN node_fts ON node_fts.version_id = v.id
1102
+ JOIN allowed ON allowed.id = n.id${scopeJoin(scopeId)}
774
1103
  WHERE n.id IN (${placeholders}) AND n.archived_at IS NULL`)
775
- .bind(...readBindings(actor), ...ids)
1104
+ .bind(...readBindings(actor), ...scopeBindings(scopeId), ...ids)
776
1105
  .all();
777
1106
  rows.push(...(result.results ?? []));
778
1107
  }
@@ -783,7 +1112,7 @@ export function createKnowledgeRepository(deps) {
783
1112
  versionId: row.version_id,
784
1113
  title: row.title,
785
1114
  passage: row.passage,
786
- source: `intel://knowledge/${encodeURIComponent(row.node_id)}?version=${encodeURIComponent(row.version_id)}`,
1115
+ source: `intel://nodes/${encodeURIComponent(row.node_id)}?version=${encodeURIComponent(row.version_id)}`,
787
1116
  freshness: row.freshness,
788
1117
  score: 0,
789
1118
  match: "semantic",
@@ -791,7 +1120,7 @@ export function createKnowledgeRepository(deps) {
791
1120
  },
792
1121
  async listCurrentVersionIds(input) {
793
1122
  const result = await deps.db
794
- .prepare(`SELECT current_version_id AS id FROM knowledge_nodes
1123
+ .prepare(`SELECT current_version_id AS id FROM nodes
795
1124
  WHERE current_version_id IS NOT NULL AND archived_at IS NULL
796
1125
  AND (? IS NULL OR current_version_id > ?)
797
1126
  ORDER BY id LIMIT ?`)