@anchrd/intel-api 0.6.7 → 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 +148 -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 +431 -118
  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 +324 -61
  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,11 +110,11 @@ 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 ?" : ""}`;
@@ -114,11 +130,70 @@ export function createKnowledgeRepository(deps) {
114
130
  */
115
131
  const archivedEverywhere = `${visibleCte}
116
132
  SELECT ${nodeColumns}, 0 AS has_children
117
- FROM knowledge_nodes n
133
+ FROM nodes n
118
134
  JOIN allowed ON allowed.id = n.id
119
135
  WHERE n.archived_at IS NOT NULL
120
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] };
121
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
+ },
122
197
  async listVisible(actor, input) {
123
198
  const result = await deps.db
124
199
  .prepare(input.archivedOnly ? archivedEverywhere : visibleChildren(false))
@@ -142,13 +217,168 @@ export function createKnowledgeRepository(deps) {
142
217
  const row = await deps.db
143
218
  .prepare(`${visibleCte}
144
219
  SELECT ${nodeColumns}
145
- FROM knowledge_nodes n
220
+ FROM nodes n
146
221
  JOIN allowed ON allowed.id = n.id
147
222
  WHERE n.id = ?`)
148
223
  .bind(...readBindings(actor), nodeId)
149
224
  .first();
150
225
  return row ? mapNode(row) : null;
151
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
+ },
152
382
  async can(actor, nodeId, verb) {
153
383
  const row = await deps.db
154
384
  .prepare(nodeVerbQuery)
@@ -167,15 +397,26 @@ export function createKnowledgeRepository(deps) {
167
397
  async findIdempotentRevocation(actorId, idempotencyKey) {
168
398
  const row = await deps.db
169
399
  .prepare(`SELECT resource_id FROM idempotency_keys
170
- WHERE actor_id = ? AND operation = 'knowledge.revoke' AND idempotency_key = ?`)
400
+ WHERE actor_id = ? AND operation = 'node.revoke' AND idempotency_key = ?`)
171
401
  .bind(actorId, idempotencyKey)
172
402
  .first();
173
403
  if (!row)
174
404
  return null;
175
405
  return row.resource_id.startsWith("1:");
176
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
+ },
177
417
  async insertNode(input) {
178
418
  const node = input.node;
419
+ const application = input.application;
179
420
  try {
180
421
  await deps.db.batch([
181
422
  deps.db
@@ -183,7 +424,7 @@ export function createKnowledgeRepository(deps) {
183
424
  // without a DEFAULT and D1 will not let it be dropped — migration 0009 carries the
184
425
  // reason. The fixed value is the price; nothing reads it, and the contract no longer
185
426
  // knows the field. When the column goes (anchrd/intel#86), this line goes with it.
186
- .prepare(`INSERT INTO knowledge_nodes (
427
+ .prepare(`INSERT INTO nodes (
187
428
  id, parent_id, kind, title, description, context_policy, owner_id,
188
429
  current_version_id, created_at, updated_at, archived_at
189
430
  ) VALUES (?, ?, ?, ?, ?, 'relevant', ?, ?, ?, ?, ?)`)
@@ -191,26 +432,42 @@ export function createKnowledgeRepository(deps) {
191
432
  deps.db
192
433
  .prepare(`INSERT INTO idempotency_keys (
193
434
  actor_id, operation, idempotency_key, resource_id, created_at
194
- ) VALUES (?, 'knowledge.create', ?, ?, ?)`)
435
+ ) VALUES (?, 'node.create', ?, ?, ?)`)
195
436
  .bind(input.actorId, input.idempotencyKey, node.id, node.createdAt),
196
437
  deps.db
197
438
  .prepare(`INSERT INTO audit_events (
198
439
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
199
- ) VALUES (?, ?, 'knowledge.create', 'knowledge', ?, ?, ?)`)
200
- .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
+ : []),
201
458
  ]);
202
459
  }
203
460
  catch (error) {
204
461
  const replayedKey = await deps.db
205
462
  .prepare(`SELECT resource_id FROM idempotency_keys
206
- WHERE actor_id = ? AND operation = 'knowledge.create' AND idempotency_key = ?`)
463
+ WHERE actor_id = ? AND operation = 'node.create' AND idempotency_key = ?`)
207
464
  .bind(input.actorId, input.idempotencyKey)
208
465
  .first();
209
466
  if (replayedKey) {
210
467
  const replayed = await deps.db
211
468
  .prepare(`${visibleCte}
212
469
  SELECT ${nodeColumns}
213
- FROM knowledge_nodes n
470
+ FROM nodes n
214
471
  JOIN allowed ON allowed.id = n.id
215
472
  WHERE n.id = ?`)
216
473
  .bind(...readBindings({ id: input.actorId, email: "" }), replayedKey.resource_id)
@@ -224,33 +481,38 @@ export function createKnowledgeRepository(deps) {
224
481
  },
225
482
  async getVersion(versionId) {
226
483
  const row = await deps.db
227
- .prepare(`SELECT id, node_id, sequence, content_key, media_type, content_hash,
228
- size, created_by, created_at FROM knowledge_versions WHERE id = ?`)
484
+ .prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
229
485
  .bind(versionId)
230
486
  .first();
231
487
  return row ? mapVersion(row) : null;
232
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.
233
491
  async listVersionContentKeys(nodeId) {
234
492
  const result = await deps.db
235
- .prepare(`SELECT content_key FROM knowledge_versions
236
- WHERE node_id = ? ORDER BY sequence`)
237
- .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)
238
500
  .all();
239
501
  return (result.results ?? []).map((row) => row.content_key);
240
502
  },
241
- async firstVersionContentKey(nodeId) {
503
+ async tableHeaderContentKey(nodeId) {
242
504
  const row = await deps.db
243
- .prepare(`SELECT content_key FROM knowledge_versions
244
- 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`)
245
508
  .bind(nodeId)
246
509
  .first();
247
510
  return row?.content_key ?? null;
248
511
  },
249
512
  async listVersions(nodeId) {
250
513
  const result = await deps.db
251
- .prepare(`SELECT id, node_id, sequence, content_key, media_type, content_hash,
252
- size, created_by, created_at
253
- FROM knowledge_versions
514
+ .prepare(`SELECT ${versionColumns}
515
+ FROM node_versions
254
516
  WHERE node_id = ?
255
517
  ORDER BY sequence DESC`)
256
518
  .bind(nodeId)
@@ -263,13 +525,13 @@ export function createKnowledgeRepository(deps) {
263
525
  await deps.db.batch([
264
526
  deps.db
265
527
  .prepare(`WITH RECURSIVE descendants(id) AS (
266
- SELECT id FROM knowledge_nodes WHERE id = ?
528
+ SELECT id FROM nodes WHERE id = ?
267
529
  UNION ALL
268
530
  SELECT child.id
269
- FROM knowledge_nodes child
531
+ FROM nodes child
270
532
  JOIN descendants parent ON child.parent_id = parent.id
271
533
  )
272
- UPDATE knowledge_nodes
534
+ UPDATE nodes
273
535
  SET parent_id = ?, title = ?, description = ?, updated_at = ?
274
536
  WHERE id = ? AND updated_at = ?
275
537
  AND (? IS NULL OR ? NOT IN (SELECT id FROM descendants))`)
@@ -277,9 +539,9 @@ export function createKnowledgeRepository(deps) {
277
539
  deps.db
278
540
  .prepare(`INSERT INTO idempotency_keys (
279
541
  actor_id, operation, idempotency_key, resource_id, created_at
280
- ) SELECT ?, 'knowledge.update', ?, ?, ?
542
+ ) SELECT ?, 'node.update', ?, ?, ?
281
543
  WHERE EXISTS (
282
- SELECT 1 FROM knowledge_nodes
544
+ SELECT 1 FROM nodes
283
545
  WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ?
284
546
  AND updated_at = ?
285
547
  )`)
@@ -287,10 +549,10 @@ export function createKnowledgeRepository(deps) {
287
549
  deps.db
288
550
  .prepare(`INSERT INTO audit_events (
289
551
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
290
- ) SELECT ?, ?, 'knowledge.update', 'knowledge', ?, ?, ?
552
+ ) SELECT ?, ?, 'node.update', 'node', ?, ?, ?
291
553
  WHERE EXISTS (
292
554
  SELECT 1 FROM idempotency_keys
293
- WHERE actor_id = ? AND operation = 'knowledge.update'
555
+ WHERE actor_id = ? AND operation = 'node.update'
294
556
  AND idempotency_key = ? AND resource_id = ?
295
557
  )`)
296
558
  .bind(input.auditId, input.actorId, node.id, JSON.stringify({
@@ -302,7 +564,7 @@ export function createKnowledgeRepository(deps) {
302
564
  catch (error) {
303
565
  const replayed = await deps.db
304
566
  .prepare(`SELECT resource_id FROM idempotency_keys
305
- WHERE actor_id = ? AND operation = 'knowledge.update' AND idempotency_key = ?`)
567
+ WHERE actor_id = ? AND operation = 'node.update' AND idempotency_key = ?`)
306
568
  .bind(input.actorId, input.idempotencyKey)
307
569
  .first();
308
570
  if (!replayed)
@@ -310,12 +572,12 @@ export function createKnowledgeRepository(deps) {
310
572
  }
311
573
  const replayed = await deps.db
312
574
  .prepare(`SELECT resource_id FROM idempotency_keys
313
- WHERE actor_id = ? AND operation = 'knowledge.update' AND idempotency_key = ?`)
575
+ WHERE actor_id = ? AND operation = 'node.update' AND idempotency_key = ?`)
314
576
  .bind(input.actorId, input.idempotencyKey)
315
577
  .first();
316
578
  if (replayed) {
317
579
  const row = await deps.db
318
- .prepare(`SELECT ${nodeColumns} FROM knowledge_nodes n WHERE n.id = ?`)
580
+ .prepare(`SELECT ${nodeColumns} FROM nodes n WHERE n.id = ?`)
319
581
  .bind(replayed.resource_id)
320
582
  .first();
321
583
  if (row)
@@ -324,10 +586,10 @@ export function createKnowledgeRepository(deps) {
324
586
  if (node.parentId !== null) {
325
587
  const cycle = await deps.db
326
588
  .prepare(`WITH RECURSIVE descendants(id) AS (
327
- SELECT id FROM knowledge_nodes WHERE id = ?
589
+ SELECT id FROM nodes WHERE id = ?
328
590
  UNION ALL
329
591
  SELECT child.id
330
- FROM knowledge_nodes child
592
+ FROM nodes child
331
593
  JOIN descendants parent ON child.parent_id = parent.id
332
594
  )
333
595
  SELECT 1 AS found FROM descendants WHERE id = ? LIMIT 1`)
@@ -342,26 +604,26 @@ export function createKnowledgeRepository(deps) {
342
604
  try {
343
605
  await deps.db.batch([
344
606
  deps.db
345
- .prepare(`UPDATE knowledge_nodes
607
+ .prepare(`UPDATE nodes
346
608
  SET archived_at = ?, updated_at = ?
347
609
  WHERE id = ? AND updated_at = ?`)
348
610
  .bind(input.archivedAt, input.updatedAt, input.nodeId, input.baseUpdatedAt),
349
611
  deps.db
350
612
  .prepare(`INSERT INTO idempotency_keys (
351
613
  actor_id, operation, idempotency_key, resource_id, created_at
352
- ) SELECT ?, 'knowledge.archive', ?, ?, ?
614
+ ) SELECT ?, 'node.archive', ?, ?, ?
353
615
  WHERE EXISTS (
354
- SELECT 1 FROM knowledge_nodes
616
+ SELECT 1 FROM nodes
355
617
  WHERE id = ? AND archived_at IS ? AND updated_at = ?
356
618
  )`)
357
619
  .bind(input.actorId, input.idempotencyKey, input.nodeId, input.updatedAt, input.nodeId, input.archivedAt, input.updatedAt),
358
620
  deps.db
359
621
  .prepare(`INSERT INTO audit_events (
360
622
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
361
- ) SELECT ?, ?, 'knowledge.archive', 'knowledge', ?, ?, ?
623
+ ) SELECT ?, ?, 'node.archive', 'node', ?, ?, ?
362
624
  WHERE EXISTS (
363
625
  SELECT 1 FROM idempotency_keys
364
- WHERE actor_id = ? AND operation = 'knowledge.archive'
626
+ WHERE actor_id = ? AND operation = 'node.archive'
365
627
  AND idempotency_key = ? AND resource_id = ?
366
628
  )`)
367
629
  .bind(input.auditId, input.actorId, input.nodeId, JSON.stringify({ archived: input.archivedAt !== null }), input.updatedAt, input.actorId, input.idempotencyKey, input.nodeId),
@@ -370,7 +632,7 @@ export function createKnowledgeRepository(deps) {
370
632
  catch (error) {
371
633
  const replayed = await deps.db
372
634
  .prepare(`SELECT resource_id FROM idempotency_keys
373
- WHERE actor_id = ? AND operation = 'knowledge.archive' AND idempotency_key = ?`)
635
+ WHERE actor_id = ? AND operation = 'node.archive' AND idempotency_key = ?`)
374
636
  .bind(input.actorId, input.idempotencyKey)
375
637
  .first();
376
638
  if (!replayed)
@@ -378,13 +640,13 @@ export function createKnowledgeRepository(deps) {
378
640
  }
379
641
  const replayed = await deps.db
380
642
  .prepare(`SELECT resource_id FROM idempotency_keys
381
- WHERE actor_id = ? AND operation = 'knowledge.archive' AND idempotency_key = ?`)
643
+ WHERE actor_id = ? AND operation = 'node.archive' AND idempotency_key = ?`)
382
644
  .bind(input.actorId, input.idempotencyKey)
383
645
  .first();
384
646
  if (!replayed)
385
647
  return "conflict";
386
648
  const row = await deps.db
387
- .prepare(`SELECT ${nodeColumns} FROM knowledge_nodes n WHERE n.id = ?`)
649
+ .prepare(`SELECT ${nodeColumns} FROM nodes n WHERE n.id = ?`)
388
650
  .bind(replayed.resource_id)
389
651
  .first();
390
652
  return row ? mapNode(row) : "conflict";
@@ -393,38 +655,39 @@ export function createKnowledgeRepository(deps) {
393
655
  const version = input.version;
394
656
  await deps.db.batch([
395
657
  deps.db
396
- .prepare(`INSERT INTO knowledge_versions (
397
- id, node_id, sequence, content_key, media_type, content_hash, size, created_by, created_at
398
- ) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?
399
- 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
400
663
  WHERE id = ? AND current_version_id IS ?`)
401
- .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),
402
665
  deps.db
403
- .prepare(`UPDATE knowledge_nodes
666
+ .prepare(`UPDATE nodes
404
667
  SET current_version_id = ?, updated_at = ?
405
- WHERE id = ? AND EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
668
+ WHERE id = ? AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
406
669
  .bind(version.id, version.createdAt, version.nodeId, version.id),
407
670
  deps.db
408
671
  .prepare(`INSERT INTO idempotency_keys (
409
672
  actor_id, operation, idempotency_key, resource_id, created_at
410
- ) SELECT ?, 'knowledge.save', ?, ?, ?
411
- WHERE EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
673
+ ) SELECT ?, 'node.save', ?, ?, ?
674
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
412
675
  .bind(input.actorId, input.idempotencyKey, version.id, version.createdAt, version.id),
413
676
  deps.db
414
- .prepare(`INSERT INTO knowledge_index_state (
677
+ .prepare(`INSERT INTO node_index_state (
415
678
  version_id, status, attempt_count, last_error, updated_at
416
679
  ) SELECT ?, 'pending', 0, NULL, ?
417
- WHERE EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
680
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
418
681
  .bind(version.id, version.createdAt, version.id),
419
682
  deps.db
420
683
  .prepare(`INSERT INTO audit_events (
421
684
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
422
- ) SELECT ?, ?, 'knowledge.save', 'knowledge', ?, ?, ?
423
- WHERE EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
685
+ ) SELECT ?, ?, 'node.save', 'node', ?, ?, ?
686
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
424
687
  .bind(input.auditId, input.actorId, version.nodeId, JSON.stringify({ versionId: version.id, sequence: version.sequence }), version.createdAt, version.id),
425
688
  ]);
426
689
  const row = await deps.db
427
- .prepare("SELECT current_version_id FROM knowledge_nodes WHERE id = ?")
690
+ .prepare("SELECT current_version_id FROM nodes WHERE id = ?")
428
691
  .bind(version.nodeId)
429
692
  .first();
430
693
  return row?.current_version_id === version.id ? "saved" : "conflict";
@@ -443,51 +706,50 @@ export function createKnowledgeRepository(deps) {
443
706
  async appendTableVersion(input) {
444
707
  const version = input.version;
445
708
  const stored = async () => await deps.db
446
- .prepare(`SELECT id, node_id, sequence, content_key, media_type, content_hash,
447
- size, created_by, created_at FROM knowledge_versions WHERE id = ?`)
709
+ .prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
448
710
  .bind(version.id)
449
711
  .first();
450
712
  try {
451
713
  await deps.db.batch([
452
714
  deps.db
453
- .prepare(`INSERT INTO knowledge_versions (
454
- 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,
455
717
  created_by, created_at
456
- ) SELECT ?, ?, COALESCE(MAX(sequence), 0) + 1, ?, ?, ?, ?, ?, ?
457
- FROM knowledge_versions WHERE node_id = ?`)
458
- .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),
459
721
  deps.db
460
- .prepare(`UPDATE knowledge_nodes
722
+ .prepare(`UPDATE nodes
461
723
  SET current_version_id = ?, updated_at = ?
462
724
  WHERE id = ?
463
- AND EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)
725
+ AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)
464
726
  AND (
465
727
  current_version_id IS NULL
466
728
  OR COALESCE((
467
- SELECT sequence FROM knowledge_versions
468
- WHERE id = knowledge_nodes.current_version_id
469
- ), 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 = ?)
470
732
  )`)
471
733
  .bind(version.id, version.createdAt, version.nodeId, version.id, version.id),
472
734
  deps.db
473
735
  .prepare(`INSERT INTO idempotency_keys (
474
736
  actor_id, operation, idempotency_key, resource_id, created_at
475
- ) SELECT ?, 'knowledge.append', ?, ?, ?
476
- WHERE EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
737
+ ) SELECT ?, 'node.append', ?, ?, ?
738
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
477
739
  .bind(input.actorId, input.idempotencyKey, version.id, version.createdAt, version.id),
478
740
  deps.db
479
- .prepare(`INSERT INTO knowledge_index_state (
741
+ .prepare(`INSERT INTO node_index_state (
480
742
  version_id, status, attempt_count, last_error, updated_at
481
743
  ) SELECT ?, 'pending', 0, NULL, ?
482
- WHERE EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
744
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
483
745
  .bind(version.id, version.createdAt, version.id),
484
746
  deps.db
485
747
  .prepare(`INSERT INTO audit_events (
486
748
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
487
- ) SELECT ?, ?, 'knowledge.append', 'knowledge', ?, json_object(
749
+ ) SELECT ?, ?, 'node.append', 'node', ?, json_object(
488
750
  'versionId', ?, 'size', ?
489
751
  ), ?
490
- WHERE EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
752
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
491
753
  .bind(input.auditId, input.actorId, version.nodeId, version.id, version.size, version.createdAt, version.id),
492
754
  ]);
493
755
  }
@@ -496,13 +758,12 @@ export function createKnowledgeRepository(deps) {
496
758
  // answers with it rather than with a failure the caller cannot act on.
497
759
  const replayed = await deps.db
498
760
  .prepare(`SELECT resource_id FROM idempotency_keys
499
- WHERE actor_id = ? AND operation = 'knowledge.append' AND idempotency_key = ?`)
761
+ WHERE actor_id = ? AND operation = 'node.append' AND idempotency_key = ?`)
500
762
  .bind(input.actorId, input.idempotencyKey)
501
763
  .first();
502
764
  const row = replayed
503
765
  ? await deps.db
504
- .prepare(`SELECT id, node_id, sequence, content_key, media_type, content_hash,
505
- size, created_by, created_at FROM knowledge_versions WHERE id = ?`)
766
+ .prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
506
767
  .bind(replayed.resource_id)
507
768
  .first()
508
769
  : null;
@@ -512,9 +773,61 @@ export function createKnowledgeRepository(deps) {
512
773
  }
513
774
  const row = await stored();
514
775
  if (!row)
515
- throw new Error("Knowledge table append did not store a version");
776
+ throw new Error("Node table append did not store a version");
516
777
  return mapVersion(row);
517
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
+ },
518
831
  async listGrants(resourceId) {
519
832
  const result = await deps.db
520
833
  .prepare(`SELECT ${grantColumns} FROM tree_grants
@@ -529,10 +842,10 @@ export function createKnowledgeRepository(deps) {
529
842
  async organizationExecuteReaches(nodeId, exceptGrantId) {
530
843
  const row = await deps.db
531
844
  .prepare(`WITH RECURSIVE ancestors(id, parent_id) AS (
532
- SELECT id, parent_id FROM knowledge_nodes WHERE id = ?
845
+ SELECT id, parent_id FROM nodes WHERE id = ?
533
846
  UNION
534
847
  SELECT parent.id, parent.parent_id
535
- FROM knowledge_nodes parent
848
+ FROM nodes parent
536
849
  JOIN ancestors child ON child.parent_id = parent.id
537
850
  )
538
851
  SELECT 1 AS reaches
@@ -551,7 +864,7 @@ export function createKnowledgeRepository(deps) {
551
864
  const result = await deps.db
552
865
  .prepare(`${visibleCte}
553
866
  SELECT ${linkColumns}
554
- FROM knowledge_links link
867
+ FROM node_links link
555
868
  JOIN allowed source_access ON source_access.id = link.source_node_id
556
869
  JOIN allowed target_access ON target_access.id = link.target_node_id
557
870
  WHERE link.source_node_id = ? OR link.target_node_id = ?
@@ -578,7 +891,7 @@ export function createKnowledgeRepository(deps) {
578
891
  const result = await deps.db
579
892
  .prepare(`${visibleCte}
580
893
  SELECT n.id, n.title
581
- FROM knowledge_nodes n
894
+ FROM nodes n
582
895
  JOIN allowed ON allowed.id = n.id
583
896
  WHERE n.id IN (${placeholders}) AND n.archived_at IS NULL`)
584
897
  .bind(...readBindings(actor), ...ids)
@@ -600,10 +913,10 @@ export function createKnowledgeRepository(deps) {
600
913
  async replaceTextLinks(input) {
601
914
  await deps.db.batch([
602
915
  deps.db
603
- .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'")
604
917
  .bind(input.sourceNodeId),
605
918
  ...input.links.map((link) => deps.db
606
- .prepare(`INSERT INTO knowledge_links (
919
+ .prepare(`INSERT INTO node_links (
607
920
  id, source_node_id, target_node_id, relation, origin, label, created_by, created_at
608
921
  ) VALUES (?, ?, ?, 'references', 'text', NULL, ?, ?)
609
922
  ON CONFLICT (source_node_id, target_node_id, relation) DO NOTHING`)
@@ -611,7 +924,7 @@ export function createKnowledgeRepository(deps) {
611
924
  deps.db
612
925
  .prepare(`INSERT INTO audit_events (
613
926
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
614
- ) VALUES (?, ?, 'knowledge.link', 'knowledge', ?, ?, ?)`)
927
+ ) VALUES (?, ?, 'node.link', 'node', ?, ?, ?)`)
615
928
  .bind(input.auditId, input.actorId, input.sourceNodeId, JSON.stringify({ targetNodeIds: input.links.map((link) => link.targetNodeId) }), input.occurredAt),
616
929
  ]);
617
930
  },
@@ -619,7 +932,7 @@ export function createKnowledgeRepository(deps) {
619
932
  const nodeResult = await deps.db
620
933
  .prepare(`${visibleCte}
621
934
  SELECT ${nodeColumns}
622
- FROM knowledge_nodes n
935
+ FROM nodes n
623
936
  JOIN allowed ON allowed.id = n.id
624
937
  WHERE n.archived_at IS NULL
625
938
  ORDER BY lower(n.title), n.id
@@ -631,14 +944,14 @@ export function createKnowledgeRepository(deps) {
631
944
  return { nodes, links: [] };
632
945
  const linkResult = await deps.db
633
946
  .prepare(`${visibleCte}, graph_nodes(id) AS (
634
- SELECT n.id FROM knowledge_nodes n
947
+ SELECT n.id FROM nodes n
635
948
  JOIN allowed ON allowed.id = n.id
636
949
  WHERE n.archived_at IS NULL
637
950
  ORDER BY lower(n.title), n.id
638
951
  LIMIT ?
639
952
  )
640
953
  SELECT ${linkColumns}
641
- FROM knowledge_links link
954
+ FROM node_links link
642
955
  JOIN graph_nodes source_node ON source_node.id = link.source_node_id
643
956
  JOIN graph_nodes target_node ON target_node.id = link.target_node_id
644
957
  ORDER BY link.created_at, link.id`)
@@ -669,13 +982,13 @@ export function createKnowledgeRepository(deps) {
669
982
  deps.db
670
983
  .prepare(`INSERT INTO idempotency_keys (
671
984
  actor_id, operation, idempotency_key, resource_id, created_at
672
- ) SELECT ?, 'knowledge.share', ?, id, ? FROM tree_grants
985
+ ) SELECT ?, 'node.share', ?, id, ? FROM tree_grants
673
986
  WHERE node_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
674
987
  .bind(input.actorId, input.idempotencyKey, grant.createdAt, grant.resourceId, principalType, principalId, grant.verb),
675
988
  deps.db
676
989
  .prepare(`INSERT INTO audit_events (
677
990
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
678
- ) VALUES (?, ?, 'knowledge.share', 'knowledge', ?, ?, ?)`)
991
+ ) VALUES (?, ?, 'node.share', 'node', ?, ?, ?)`)
679
992
  .bind(input.auditId, input.actorId, grant.resourceId, JSON.stringify({ principalType, verb: grant.verb }), grant.createdAt),
680
993
  ]);
681
994
  const stored = await deps.db
@@ -684,13 +997,13 @@ export function createKnowledgeRepository(deps) {
684
997
  .bind(grant.resourceId, principalType, principalId, grant.verb)
685
998
  .first();
686
999
  if (!stored)
687
- throw new Error("Knowledge grant disappeared after upsert");
1000
+ throw new Error("Node grant disappeared after upsert");
688
1001
  return mapGrant(stored);
689
1002
  }
690
1003
  catch (error) {
691
1004
  const replayed = await deps.db
692
1005
  .prepare(`SELECT resource_id FROM idempotency_keys
693
- WHERE actor_id = ? AND operation = 'knowledge.share' AND idempotency_key = ?`)
1006
+ WHERE actor_id = ? AND operation = 'node.share' AND idempotency_key = ?`)
694
1007
  .bind(input.actorId, input.idempotencyKey)
695
1008
  .first();
696
1009
  if (replayed) {
@@ -710,7 +1023,7 @@ export function createKnowledgeRepository(deps) {
710
1023
  deps.db
711
1024
  .prepare(`INSERT INTO idempotency_keys (
712
1025
  actor_id, operation, idempotency_key, resource_id, created_at
713
- ) SELECT ?, 'knowledge.revoke', ?,
1026
+ ) SELECT ?, 'node.revoke', ?,
714
1027
  (CASE WHEN EXISTS (
715
1028
  SELECT 1 FROM tree_grants WHERE id = ? AND node_id = ?
716
1029
  ) THEN '1:' ELSE '0:' END) || ?, ?`)
@@ -721,13 +1034,13 @@ export function createKnowledgeRepository(deps) {
721
1034
  deps.db
722
1035
  .prepare(`INSERT INTO audit_events (
723
1036
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
724
- ) SELECT ?, ?, 'knowledge.revoke', 'knowledge', ?,
1037
+ ) SELECT ?, ?, 'node.revoke', 'node', ?,
725
1038
  json_object(
726
1039
  'grantId', ?,
727
1040
  'revoked', json(CASE WHEN substr(resource_id, 1, 2) = '1:' THEN 'true' ELSE 'false' END)
728
1041
  ), ?
729
1042
  FROM idempotency_keys
730
- WHERE actor_id = ? AND operation = 'knowledge.revoke' AND idempotency_key = ?`)
1043
+ WHERE actor_id = ? AND operation = 'node.revoke' AND idempotency_key = ?`)
731
1044
  .bind(input.auditId, input.actorId, input.resourceId, input.grantId, input.occurredAt, input.actorId, input.idempotencyKey),
732
1045
  ]);
733
1046
  }
@@ -744,33 +1057,33 @@ export function createKnowledgeRepository(deps) {
744
1057
  if (!query)
745
1058
  return [];
746
1059
  const result = await deps.db
747
- .prepare(`${visibleCte}
1060
+ .prepare(`${visibleCte}${scopeCte(input.scopeId)}
748
1061
  SELECT n.id AS node_id, v.id AS version_id, n.title,
749
- snippet(knowledge_fts, 3, '', '', ' … ', 32) AS passage,
750
- n.updated_at AS freshness, bm25(knowledge_fts, 0.0, 0.0, 4.0, 1.0) AS rank
751
- FROM knowledge_fts
752
- JOIN knowledge_nodes n ON n.id = knowledge_fts.node_id
753
- JOIN knowledge_versions v ON v.id = knowledge_fts.version_id
754
- JOIN allowed ON allowed.id = n.id
755
- 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 ?
756
1069
  AND n.current_version_id = v.id
757
1070
  AND n.archived_at IS NULL
758
1071
  ORDER BY rank, n.updated_at DESC
759
1072
  LIMIT ?`)
760
- .bind(...readBindings(actor), query, input.limit)
1073
+ .bind(...readBindings(actor), ...scopeBindings(input.scopeId), query, input.limit)
761
1074
  .all();
762
1075
  return (result.results ?? []).map((row) => ({
763
1076
  nodeId: row.node_id,
764
1077
  versionId: row.version_id,
765
1078
  title: row.title,
766
1079
  passage: row.passage,
767
- 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)}`,
768
1081
  freshness: row.freshness,
769
1082
  score: 1 / (1 + Math.abs(row.rank)),
770
1083
  match: "lexical",
771
1084
  }));
772
1085
  },
773
- async hydrateVisibleCitations(actor, nodeIds) {
1086
+ async hydrateVisibleCitations(actor, nodeIds, scopeId) {
774
1087
  const uniqueIds = [...new Set(nodeIds)].slice(0, 100);
775
1088
  if (uniqueIds.length === 0)
776
1089
  return [];
@@ -779,16 +1092,16 @@ export function createKnowledgeRepository(deps) {
779
1092
  const ids = uniqueIds.slice(offset, offset + 96);
780
1093
  const placeholders = ids.map(() => "?").join(", ");
781
1094
  const result = await deps.db
782
- .prepare(`${visibleCte}
1095
+ .prepare(`${visibleCte}${scopeCte(scopeId)}
783
1096
  SELECT n.id AS node_id, v.id AS version_id, n.title,
784
- substr(knowledge_fts.content, 1, 480) AS passage,
1097
+ substr(node_fts.content, 1, 480) AS passage,
785
1098
  n.updated_at AS freshness
786
- FROM knowledge_nodes n
787
- JOIN knowledge_versions v ON v.id = n.current_version_id
788
- JOIN knowledge_fts ON knowledge_fts.version_id = v.id
789
- 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)}
790
1103
  WHERE n.id IN (${placeholders}) AND n.archived_at IS NULL`)
791
- .bind(...readBindings(actor), ...ids)
1104
+ .bind(...readBindings(actor), ...scopeBindings(scopeId), ...ids)
792
1105
  .all();
793
1106
  rows.push(...(result.results ?? []));
794
1107
  }
@@ -799,7 +1112,7 @@ export function createKnowledgeRepository(deps) {
799
1112
  versionId: row.version_id,
800
1113
  title: row.title,
801
1114
  passage: row.passage,
802
- 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)}`,
803
1116
  freshness: row.freshness,
804
1117
  score: 0,
805
1118
  match: "semantic",
@@ -807,7 +1120,7 @@ export function createKnowledgeRepository(deps) {
807
1120
  },
808
1121
  async listCurrentVersionIds(input) {
809
1122
  const result = await deps.db
810
- .prepare(`SELECT current_version_id AS id FROM knowledge_nodes
1123
+ .prepare(`SELECT current_version_id AS id FROM nodes
811
1124
  WHERE current_version_id IS NOT NULL AND archived_at IS NULL
812
1125
  AND (? IS NULL OR current_version_id > ?)
813
1126
  ORDER BY id LIMIT ?`)