@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
@@ -0,0 +1,1337 @@
1
+ import { AgentDefinition, AgentMediaType, TableMediaType, } from "@anchrd/intel-contract";
2
+ import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
3
+ import { IntelError } from "../shared/intel-error/intel-error.js";
4
+ import { documentLinkTargets } from "./document-links/document-links.js";
5
+ // ⚠️ The R2 key of a version written before #125 begins `knowledge/`, and it stays that way. A key
6
+ // is stored in `node_versions.content_key` and read back from there; nothing derives one from ids,
7
+ // and nothing lists the bucket by prefix. So the two prefixes cost nothing, while rewriting the old
8
+ // ones would mean copying every object in the bucket to change a string nobody reads.
9
+ function contentKeyFor(nodeId, versionId) {
10
+ return `nodes/${nodeId}/versions/${versionId}`;
11
+ }
12
+ // A verb that cannot apply to a node is neither offered on it nor accepted for it (ADR-0004 §2).
13
+ // The answer lives here rather than in the screen so HTTP, MCP and the UI cannot disagree about it.
14
+ //
15
+ // `execute` is meaningful where something can be run: a folder, because only a folder can hold a
16
+ // flow, and an agent, where it means being allowed to USE it (#139). Reading an agent's definition
17
+ // and being permitted to put it to work are separate questions — the whole reason the verbs are
18
+ // granted independently rather than as a ladder — and an agent is the case that makes the
19
+ // difference obvious: its definition is inspectable by design (ADR-0005 §2), so `read` on it must
20
+ // not imply the right to set it going.
21
+ function applicableVerbs(kind) {
22
+ return kind === "folder" || kind === "agent"
23
+ ? ["read", "write", "execute", "share"]
24
+ : ["read", "write", "share"];
25
+ }
26
+ // ⚠️ The refusal has to be actionable without becoming a directory of the tree. Whoever holds
27
+ // `share` on one folder must not learn the titles of flows they may not see, so the ones they may
28
+ // see are named and the rest are only counted (ADR-0004 §3, and #17's review).
29
+ function callersDetail(callers) {
30
+ const named = callers.visible.map((title) => `“${title}”`).join(", ");
31
+ const rest = callers.hidden === 0
32
+ ? ""
33
+ : `${named ? " and " : ""}${callers.hidden} more flow${callers.hidden === 1 ? "" : "s"} you cannot see`;
34
+ return `Flows outside this folder call into it: ${named}${rest}. Change or unpublish them before narrowing the folder.`;
35
+ }
36
+ // The grantee as the ACL sees them, and as nothing else: an identity with no capability of its own,
37
+ // never `isAdmin`. It answers for Intel's resource ACLs only — whether Gate hands this person
38
+ // `intel/admin` is Gate's to know, so the warning below can be pessimistic and never permissive.
39
+ // A grant to an email address is judged as that address, which is what the grant will be attached
40
+ // to; a second grant the same person holds under their user ID is not folded in.
41
+ function asPrincipalActor(principal) {
42
+ if (principal.type === "user")
43
+ return { id: principal.id, email: "" };
44
+ if (principal.type === "email")
45
+ return { id: "", email: principal.email };
46
+ return { id: "", email: "" };
47
+ }
48
+ /**
49
+ * A stored definition, parsed rather than trusted — the version boundary the repository rule names
50
+ * (`CLAUDE.md`, "stored Flow graphs read across a version boundary").
51
+ *
52
+ * ⚠️ It fails loudly on purpose. Intel is the only writer of this body, so a definition that does
53
+ * not parse is corruption, not an older shape to be coerced into the current one. Reading it
54
+ * leniently would hand the runtime an agent that has quietly forgotten its references, and the
55
+ * first sign of it would be an agent answering without the knowledge it was given.
56
+ */
57
+ function parseStoredDefinition(body) {
58
+ let json;
59
+ try {
60
+ json = JSON.parse(body);
61
+ }
62
+ catch {
63
+ throw new IntelError(500, "agent_definition_invalid", "Agent definition is not valid JSON");
64
+ }
65
+ const parsed = AgentDefinition.safeParse(json);
66
+ if (!parsed.success) {
67
+ throw new IntelError(500, "agent_definition_invalid", "Agent definition does not match the contract");
68
+ }
69
+ return parsed.data;
70
+ }
71
+ function decodeBase64(value) {
72
+ if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
73
+ throw new IntelError(400, "attachment_invalid", "Attachment content is not valid base64");
74
+ }
75
+ try {
76
+ return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
77
+ }
78
+ catch {
79
+ throw new IntelError(400, "attachment_invalid", "Attachment content is not valid base64");
80
+ }
81
+ }
82
+ export function createNodes(deps) {
83
+ function mergeSearchResults(lexical, semantic, semanticScores, limit) {
84
+ const merged = new Map();
85
+ for (const citation of lexical) {
86
+ merged.set(citation.nodeId, {
87
+ citation,
88
+ lexicalScore: citation.score,
89
+ semanticScore: undefined,
90
+ });
91
+ }
92
+ for (const citation of semantic) {
93
+ const current = merged.get(citation.nodeId);
94
+ merged.set(citation.nodeId, {
95
+ citation: current?.citation ?? citation,
96
+ lexicalScore: current?.lexicalScore,
97
+ semanticScore: semanticScores.get(citation.nodeId) ?? citation.score,
98
+ });
99
+ }
100
+ return [...merged.values()]
101
+ .map(({ citation, lexicalScore, semanticScore }) => {
102
+ const score = lexicalScore !== undefined && semanticScore !== undefined
103
+ ? lexicalScore * 0.45 + semanticScore * 0.55
104
+ : lexicalScore !== undefined
105
+ ? lexicalScore * 0.9
106
+ : (semanticScore ?? 0) * 0.85;
107
+ return {
108
+ ...citation,
109
+ score: Math.max(0, Math.min(1, score)),
110
+ match: lexicalScore !== undefined && semanticScore !== undefined
111
+ ? "hybrid"
112
+ : lexicalScore !== undefined
113
+ ? "lexical"
114
+ : "semantic",
115
+ };
116
+ })
117
+ .sort((left, right) => right.score - left.score || right.freshness.localeCompare(left.freshness))
118
+ .slice(0, limit);
119
+ }
120
+ /**
121
+ * A table's body: every version's R2 object joined in sequence order (#40).
122
+ *
123
+ * ⚠️ This is the price of appending without reading. Each `append` writes one immutable object
124
+ * holding only the rows it added, so the write is O(new rows) and two concurrent appends cannot
125
+ * overwrite one another — but a read of a table with k appends costs k R2 gets. That is the
126
+ * trade the ticket asks for by name: the write side is the hot path an agent uses on a schedule,
127
+ * the read side is a person opening a grid or one indexing pass. The gets are issued together
128
+ * rather than in a chain so the cost is k requests, not k round trips; compacting old segments
129
+ * into one object is a later ticket, and it can happen without changing anything a caller sees
130
+ * because the version rows stay the history either way.
131
+ */
132
+ async function tableContent(node) {
133
+ if (node.currentVersionId === null)
134
+ return "";
135
+ // ⚠️ One statement for every segment key, never one per segment, and only the keys rather than
136
+ // the whole version rows. The number of D1 round trips a table costs must not grow with the
137
+ // number of times it has been appended to (#30).
138
+ const keys = await deps.repository.listVersionContentKeys(node.id);
139
+ const segments = await Promise.all(keys.map(async (key) => await deps.content.get(key)));
140
+ if (segments.some((segment) => segment === null)) {
141
+ throw new IntelError(500, "content_missing", "Version content is missing");
142
+ }
143
+ return segments.join("");
144
+ }
145
+ /**
146
+ * The column names, read from the first version alone.
147
+ *
148
+ * ⚠️ Deliberately not `tableContent`, and deliberately not the version list either. Checking an
149
+ * append against the header runs on every append, so it must cost the same on a table of ten rows
150
+ * and on one of ten thousand: one statement that returns one key, and one small R2 read. Reading
151
+ * every version row to look at the first would be a cost that grows with the history — one query,
152
+ * but more of it every time (#30, #40).
153
+ */
154
+ async function tableHeader(node) {
155
+ if (node.currentVersionId === null)
156
+ return null;
157
+ const key = await deps.repository.tableHeaderContentKey(node.id);
158
+ if (key === null)
159
+ return null;
160
+ const body = await deps.content.get(key);
161
+ if (body === null)
162
+ throw new IntelError(500, "content_missing", "Version content is missing");
163
+ return parseCsv(body)[0] ?? null;
164
+ }
165
+ async function getDocument(node) {
166
+ if (node.currentVersionId === null)
167
+ return { node, version: null, content: null };
168
+ const version = await deps.repository.getVersion(node.currentVersionId);
169
+ if (!version)
170
+ throw new IntelError(500, "version_missing", "Current version is missing");
171
+ if (node.kind === "attachment")
172
+ return { node, version, content: null };
173
+ // A table answers with the whole CSV, so downloading, citing and reading it over MCP all get
174
+ // the same bytes a person sees in the grid — the format is the export (#40).
175
+ if (node.kind === "table")
176
+ return { node, version, content: await tableContent(node) };
177
+ const content = await deps.content.get(version.contentKey);
178
+ if (content === null)
179
+ throw new IntelError(500, "content_missing", "Version content is missing");
180
+ return { node, version, content };
181
+ }
182
+ async function tableOf(node) {
183
+ const [header = [], ...rows] = parseCsv(await tableContent(node));
184
+ return { node, columns: header, rows, versionId: node.currentVersionId };
185
+ }
186
+ /**
187
+ * One immutable object holding only what this write adds, plus the version row that orders it.
188
+ *
189
+ * ⚠️ The R2 object is written before the version row and is deleted again if the row does not
190
+ * land, exactly as `save` does it. An orphaned object is invisible; a version row pointing at
191
+ * nothing is a table that cannot be read at all.
192
+ */
193
+ async function writeTableSegment(actor, node, body, segment, idempotencyKey) {
194
+ const versionId = deps.id();
195
+ const contentKey = contentKeyFor(node.id, versionId);
196
+ await deps.content.put(contentKey, body, TableMediaType);
197
+ try {
198
+ return await deps.repository.appendTableVersion({
199
+ version: {
200
+ id: versionId,
201
+ nodeId: node.id,
202
+ contentKey,
203
+ mediaType: TableMediaType,
204
+ contentHash: await deps.hash(body),
205
+ size: new TextEncoder().encode(body).byteLength,
206
+ segment,
207
+ createdBy: actor.id,
208
+ createdAt: deps.now().toISOString(),
209
+ },
210
+ actorId: actor.id,
211
+ idempotencyKey,
212
+ auditId: deps.id(),
213
+ });
214
+ }
215
+ catch (error) {
216
+ await deps.content.delete(contentKey).catch(() => undefined);
217
+ throw error;
218
+ }
219
+ }
220
+ /**
221
+ * One snapshot — the complete table after a mutation — written only against the state the caller
222
+ * read (#135).
223
+ *
224
+ * ⚠️ The base check lives in the repository's INSERT, not in a read here: between reading the
225
+ * table and writing its replacement another segment can always land, and only the statement that
226
+ * inserts the row can refuse at the moment it matters. On "conflict" the R2 object is removed
227
+ * again and the idempotency table is asked once more, exactly as `save` does it — the conflict
228
+ * may be this very mutation, already written by a racing retry with the same key.
229
+ */
230
+ async function writeTableSnapshot(input) {
231
+ const versionId = deps.id();
232
+ const contentKey = contentKeyFor(input.node.id, versionId);
233
+ const version = {
234
+ id: versionId,
235
+ nodeId: input.node.id,
236
+ sequence: await nextSequence(input.node),
237
+ contentKey,
238
+ mediaType: TableMediaType,
239
+ contentHash: await deps.hash(input.body),
240
+ size: new TextEncoder().encode(input.body).byteLength,
241
+ segment: "snapshot",
242
+ createdBy: input.actor.id,
243
+ createdAt: deps.now().toISOString(),
244
+ };
245
+ await deps.content.put(contentKey, input.body, TableMediaType);
246
+ let saved;
247
+ try {
248
+ saved = await deps.repository.appendTableSnapshot({
249
+ version,
250
+ actorId: input.actor.id,
251
+ baseVersionId: input.baseVersionId,
252
+ operation: input.operation,
253
+ metadata: input.metadata,
254
+ idempotencyKey: input.idempotencyKey,
255
+ auditId: deps.id(),
256
+ });
257
+ }
258
+ catch (error) {
259
+ await deps.content.delete(contentKey).catch(() => undefined);
260
+ throw error;
261
+ }
262
+ if (saved === "conflict") {
263
+ await deps.content.delete(contentKey);
264
+ const replayedId = await deps.repository.findIdempotentNode(input.actor.id, input.operation, input.idempotencyKey);
265
+ if (replayedId) {
266
+ const replayed = await deps.repository.getVersion(replayedId);
267
+ if (replayed)
268
+ return replayed;
269
+ }
270
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
271
+ }
272
+ await deps.indexing.enqueue(version.id);
273
+ return version;
274
+ }
275
+ /**
276
+ * The current state a mutation addresses: header and rows, read only after the base was checked.
277
+ *
278
+ * ⚠️ The refusal for a stale base comes before the content is read, so a caller with an outdated
279
+ * `baseVersionId` costs one D1 row and no R2 traffic — and the position validation that follows
280
+ * never runs against rows the caller was not looking at.
281
+ */
282
+ async function tableStateFor(node, baseVersionId) {
283
+ if (node.currentVersionId === null) {
284
+ throw new IntelError(409, "table_undefined", "This table has no header yet; define its columns before changing rows");
285
+ }
286
+ if (node.currentVersionId !== baseVersionId) {
287
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
288
+ }
289
+ const [header, ...rows] = parseCsv(await tableContent(node));
290
+ if (!header)
291
+ throw new IntelError(500, "content_missing", "Table header is missing");
292
+ return { header, rows };
293
+ }
294
+ // The same refusal an append gives, for the same reason: a row that does not fit the header is a
295
+ // caller that believes the table has a different shape, and quietly filling or cutting the gap
296
+ // would store that misunderstanding as data (#40, unchanged by #135).
297
+ function requireRowShape(rows, header) {
298
+ const wrong = rows.findIndex((row) => row.length !== header.length);
299
+ if (wrong !== -1) {
300
+ throw new IntelError(400, "table_row_shape", `Row ${wrong + 1} has ${rows[wrong]?.length ?? 0} cells but the table has ${header.length} columns: ${header.join(", ")}`);
301
+ }
302
+ }
303
+ // A position that misses the table is the same kind of misunderstanding as a row that misses the
304
+ // header: refused whole, nothing written, and the message says what the table actually holds.
305
+ function requirePositions(positions, rowCount) {
306
+ const outside = positions.find((position) => position >= rowCount);
307
+ if (outside !== undefined) {
308
+ throw new IntelError(400, "table_row_position", `Row position ${outside} is out of range: the table has ${rowCount} row${rowCount === 1 ? "" : "s"}`);
309
+ }
310
+ }
311
+ async function agentOf(node) {
312
+ if (node.kind !== "agent") {
313
+ throw new IntelError(409, "not_an_agent", "This node is not an agent");
314
+ }
315
+ // The Application ID rides on every agent read (#182). It is a name and not a credential, so
316
+ // there is nothing to withhold — and the surface that draws an agent has to be able to say
317
+ // whether it has a principal at all, because an agent without one runs nothing.
318
+ const applicationId = await deps.repository.agentApplicationId(node.id);
319
+ if (node.currentVersionId === null) {
320
+ return { node, version: null, definition: null, applicationId };
321
+ }
322
+ const version = await deps.repository.getVersion(node.currentVersionId);
323
+ if (!version)
324
+ throw new IntelError(500, "version_missing", "Current version is missing");
325
+ const body = await deps.content.get(version.contentKey);
326
+ if (body === null)
327
+ throw new IntelError(500, "content_missing", "Version content is missing");
328
+ return { node, version, definition: parseStoredDefinition(body), applicationId };
329
+ }
330
+ /**
331
+ * One immutable definition version, written exactly the way a document's content is (ADR-0005 §1).
332
+ *
333
+ * ⚠️ R2 object first, version row second, R2 object deleted again if the row does not land — the
334
+ * same order `save` keeps and for the same reason: an orphaned object is invisible, while a
335
+ * version row pointing at nothing is an agent that cannot be read at all.
336
+ *
337
+ * ⚠️ The body is serialized from the PARSED definition, never from the caller's JSON text. What
338
+ * is stored is therefore always what the contract accepted, and a field the strict schema refused
339
+ * cannot reach R2 by riding along in the original string.
340
+ *
341
+ * ⚠️ `segment` is `null`, like a document's and unlike a table's (#135): every version here holds
342
+ * the whole definition, so there is nothing for a snapshot to mark off from what came before it.
343
+ */
344
+ async function writeAgentVersion(actor, node, definition, baseVersionId, idempotencyKey) {
345
+ const body = JSON.stringify(definition);
346
+ const versionId = deps.id();
347
+ const contentKey = contentKeyFor(node.id, versionId);
348
+ const version = {
349
+ id: versionId,
350
+ nodeId: node.id,
351
+ sequence: await nextSequence(node),
352
+ contentKey,
353
+ mediaType: AgentMediaType,
354
+ contentHash: await deps.hash(body),
355
+ size: new TextEncoder().encode(body).byteLength,
356
+ segment: null,
357
+ createdBy: actor.id,
358
+ createdAt: deps.now().toISOString(),
359
+ };
360
+ await deps.content.put(contentKey, body, AgentMediaType);
361
+ let saved;
362
+ try {
363
+ saved = await deps.repository.appendVersion({
364
+ version,
365
+ actorId: actor.id,
366
+ baseVersionId,
367
+ idempotencyKey,
368
+ auditId: deps.id(),
369
+ });
370
+ }
371
+ catch (error) {
372
+ await deps.content.delete(contentKey).catch(() => undefined);
373
+ throw error;
374
+ }
375
+ if (saved === "conflict") {
376
+ await deps.content.delete(contentKey);
377
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
378
+ }
379
+ await deps.indexing.enqueue(version.id);
380
+ return {
381
+ node: await requireVisible(actor, node.id),
382
+ version,
383
+ definition,
384
+ applicationId: await deps.repository.agentApplicationId(node.id),
385
+ };
386
+ }
387
+ async function requireTable(actor, nodeId) {
388
+ const node = await requireVisible(actor, nodeId);
389
+ if (node.kind !== "table") {
390
+ throw new IntelError(409, "not_a_table", "Only tables accept rows");
391
+ }
392
+ if (!(await deps.repository.can(actor, node.id, "write"))) {
393
+ throw new IntelError(403, "node_forbidden", "Table cannot be edited");
394
+ }
395
+ return node;
396
+ }
397
+ /**
398
+ * What the grant just written does not cover: the documents the flows in this folder read that
399
+ * the new principal still cannot.
400
+ *
401
+ * ⚠️ It warns and never blocks. A node reference across the folder edge is a possible
402
+ * failure, not a way around permissions — that distinction is the whole of ADR-0004 §4, and a
403
+ * block here would force everyone using one central policy document to duplicate it.
404
+ *
405
+ * ⚠️ Which documents may be named is `getVisible`, the lookup every other node read goes
406
+ * through, and never a second rule written for a message: the ones this actor may see are named,
407
+ * the rest are counted. That is exactly where #17 and #19 went wrong in review.
408
+ *
409
+ * The flow side is filtered by the flow list's own predicate as well. It changes nothing today —
410
+ * sharing needs `read` on the folder and `read` inherits over the whole subtree, so every flow in
411
+ * it is already visible — but the verbs are independent by decision (ADR-0004 §2), and the day
412
+ * `share` stops implying `read` this must not be the place that quietly starts leaking.
413
+ *
414
+ * It does say one thing about the grantee: that a named document is not readable for them. That
415
+ * is the consequence of this actor's own decision, on their own folder, at the moment they make
416
+ * it, and it is the smallest answer that lets them make it — the alternative is the flow failing
417
+ * for someone else next week with nobody able to say why. It never enumerates the principal's
418
+ * access in general and never leaves the documents these flows actually name.
419
+ */
420
+ /**
421
+ * The nodes the agents in this folder's subtree name in their definitions (#139, ADR-0005 §2).
422
+ *
423
+ * ⚠️ Read from R2 and parsed, because an agent's references live in its definition body and not
424
+ * in D1 — unlike a flow's, which sit in `flow_versions.graph_json` and can be matched in SQL.
425
+ * There is no query that can answer this, and writing one against a guessed JSON shape is exactly
426
+ * how the flow side came to match a graph that migration 0008 had already abolished
427
+ * (anchrd/intel#153): the query kept returning nothing and the warning silently stopped arriving.
428
+ * Parsing the real contract schema is what makes this one fail loudly instead of quietly.
429
+ *
430
+ * ⚠️ A definition that will not parse is SKIPPED rather than thrown — the opposite of the read
431
+ * path above, deliberately. This runs while somebody is sharing a folder, and a warning that
432
+ * raises would turn one broken agent into a folder nobody can share. The read path is where a
433
+ * corrupt definition has to be loud; here the cost of loudness is paid by the wrong person.
434
+ */
435
+ async function agentNodeReferences(actor, folderId) {
436
+ const keys = await deps.repository.listVisibleAgentDefinitionKeys(actor, folderId);
437
+ const referenced = [];
438
+ for (const key of keys) {
439
+ const body = await deps.content.get(key);
440
+ if (body === null)
441
+ continue;
442
+ try {
443
+ const parsed = AgentDefinition.safeParse(JSON.parse(body));
444
+ if (!parsed.success)
445
+ continue;
446
+ for (const reference of parsed.data.references)
447
+ referenced.push(reference.nodeId);
448
+ }
449
+ catch {
450
+ // Unparsable JSON is skipped for the reason above: this runs inside a share, and one
451
+ // broken agent must not be able to make a folder unshareable.
452
+ }
453
+ }
454
+ return referenced;
455
+ }
456
+ async function unreadableForPrincipal(actor, folderId, principal) {
457
+ const grantee = asPrincipalActor(principal);
458
+ const titles = [];
459
+ let hidden = 0;
460
+ // Flows and agents reach for material the same way and the grant covers neither, so they are
461
+ // one list. Deduplicated, because a document that is both a flow's input and an agent's system
462
+ // message is one thing the new principal cannot read, not two.
463
+ const referenced = [
464
+ ...new Set([
465
+ ...(await deps.flowNodeReferences(actor, folderId)),
466
+ ...(await agentNodeReferences(actor, folderId)),
467
+ ]),
468
+ ];
469
+ for (const resourceId of referenced) {
470
+ if (await deps.repository.can(grantee, resourceId, "read"))
471
+ continue;
472
+ const node = await deps.repository.getVisible(actor, resourceId);
473
+ if (node)
474
+ titles.push(node.title);
475
+ else
476
+ hidden += 1;
477
+ }
478
+ return { titles, hidden };
479
+ }
480
+ /**
481
+ * The graph, brought in line with what the saved document actually says (#41).
482
+ *
483
+ * A text link is the relationship — there is no second way to make one any more — so the links
484
+ * of a document are rewritten every time it is saved: what is no longer written is no longer
485
+ * there, and what was added is.
486
+ *
487
+ * ⚠️ Only targets this actor may see become links. The author can only insert what the picker
488
+ * offers them, but `node_save` takes any content over MCP, and an unfiltered write would
489
+ * turn the graph into a place where the existence of an unreachable document can be confirmed by
490
+ * anyone who guesses its ID. The filter is `resolveVisibleTitles`, the same lookup the reader's
491
+ * side goes through — one rule, not two.
492
+ *
493
+ * ⚠️ Rows made in the removed dialog are `manual` and are left alone. Saving a document must not
494
+ * silently delete a relationship somebody entered before there was another way to enter one.
495
+ */
496
+ async function reconcileTextLinks(actor, sourceNodeId, mediaType, content) {
497
+ const written = documentLinkTargets(mediaType, content).filter((id) => id !== sourceNodeId);
498
+ const visible = written.length === 0
499
+ ? []
500
+ : (await deps.repository.resolveVisibleTitles(actor, written)).map((entry) => entry.nodeId);
501
+ await deps.repository.replaceTextLinks({
502
+ sourceNodeId,
503
+ links: visible.map((targetNodeId) => ({ id: deps.id(), targetNodeId })),
504
+ actorId: actor.id,
505
+ auditId: deps.id(),
506
+ occurredAt: deps.now().toISOString(),
507
+ });
508
+ }
509
+ async function requireVisible(actor, nodeId) {
510
+ const node = await deps.repository.getVisible(actor, nodeId);
511
+ if (!node)
512
+ throw new IntelError(404, "node_not_found", "Node was not found");
513
+ return node;
514
+ }
515
+ async function nextSequence(node) {
516
+ if (!node.currentVersionId)
517
+ return 1;
518
+ const current = await deps.repository.getVersion(node.currentVersionId);
519
+ if (!current) {
520
+ throw new IntelError(500, "version_missing", "Current node version is missing");
521
+ }
522
+ return current.sequence + 1;
523
+ }
524
+ async function attachment(actor, nodeId) {
525
+ const node = await requireVisible(actor, nodeId);
526
+ if (node.kind !== "attachment" || !node.currentVersionId) {
527
+ throw new IntelError(404, "attachment_not_found", "Attachment was not found");
528
+ }
529
+ const version = await deps.repository.getVersion(node.currentVersionId);
530
+ if (!version)
531
+ throw new IntelError(500, "version_missing", "Current version is missing");
532
+ return {
533
+ node,
534
+ version,
535
+ resourceUri: `intel://nodes/${encodeURIComponent(node.id)}/attachment`,
536
+ };
537
+ }
538
+ return {
539
+ async list(actor, input) {
540
+ return await deps.repository.listVisible(actor, input);
541
+ },
542
+ // The same level under a bound, for the one caller that draws a bounded picture of it. It goes
543
+ // through the same predicate as `list`, so what is drawn is a prefix of what is listed and never
544
+ // a different selection (#30).
545
+ async childrenBounded(actor, input) {
546
+ return await deps.repository.listVisibleBounded(actor, input);
547
+ },
548
+ async get(actor, nodeId) {
549
+ return await getDocument(await requireVisible(actor, nodeId));
550
+ },
551
+ /**
552
+ * The content of one pinned version (#147). Search citations pin the version they quoted, and
553
+ * without this read a citation could name text no surface can show any more.
554
+ *
555
+ * ⚠️ Authorization first, existence second: `requireVisible` answers 404 for a node this actor
556
+ * may not reach before the version table is asked anything, so probing version IDs proves
557
+ * nothing about nodes one cannot see. A version of ANOTHER node is the same 404 — a version ID
558
+ * is not an address of its own, only a position in the history of a node one may read.
559
+ *
560
+ * A table version answers with that segment's own rows (an append) or its snapshot state —
561
+ * deliberately not the assembled table of that moment, which is what `getTable` answers for
562
+ * the present. An attachment version answers with its metadata and `content: null`, the same
563
+ * shape `get` gives an attachment: old attachment bytes stay out of scope here (#147 allows
564
+ * that explicitly), because inlining them is the attachment resource's decision, not this one's.
565
+ */
566
+ async getVersion(actor, nodeId, versionId) {
567
+ const node = await requireVisible(actor, nodeId);
568
+ const version = await deps.repository.getVersion(versionId);
569
+ if (!version || version.nodeId !== node.id) {
570
+ throw new IntelError(404, "version_not_found", "Version was not found");
571
+ }
572
+ if (node.kind === "attachment")
573
+ return { node, version, content: null };
574
+ const content = await deps.content.get(version.contentKey);
575
+ if (content === null) {
576
+ throw new IntelError(500, "content_missing", "Version content is missing");
577
+ }
578
+ return { node, version, content };
579
+ },
580
+ // Nodes and Flows share one folder tree (ADR-0004), so Flows has to ask one question about
581
+ // it: may this actor file something in that folder. The answer stays here, with the tree and
582
+ // its ACLs, rather than being reimplemented on the flow side.
583
+ async folderAccess(actor, folderId) {
584
+ const folder = await deps.repository.getVisible(actor, folderId);
585
+ if (!folder || folder.archivedAt)
586
+ return "missing";
587
+ if (folder.kind !== "folder")
588
+ return "not-a-folder";
589
+ return (await deps.repository.can(actor, folder.id, "write")) ? "ok" : "forbidden";
590
+ },
591
+ async create(actor, input) {
592
+ // The generic path can file an agent row too — one without a definition or a principal — so
593
+ // the #190 gate stands here as well. Only where no runtime exists: with one, this path stays
594
+ // exactly as it was, which is part of the same ticket.
595
+ if (input.kind === "agent" && !deps.agentRuntimeAvailable()) {
596
+ throw new IntelError(503, "agent_runtime_not_configured", "This installation has no agent runtime, so agents cannot be created");
597
+ }
598
+ const existingId = await deps.repository.findIdempotentNode(actor.id, "node.create", input.idempotencyKey);
599
+ if (existingId)
600
+ return await requireVisible(actor, existingId);
601
+ if (input.parentId !== null && !(await deps.repository.can(actor, input.parentId, "write"))) {
602
+ throw new IntelError(403, "node_forbidden", "Parent folder cannot be edited");
603
+ }
604
+ const timestamp = deps.now().toISOString();
605
+ return await deps.repository.insertNode({
606
+ node: {
607
+ id: deps.id(),
608
+ parentId: input.parentId,
609
+ kind: input.kind,
610
+ title: input.title,
611
+ description: input.description,
612
+ ownerId: actor.id,
613
+ currentVersionId: null,
614
+ createdAt: timestamp,
615
+ updatedAt: timestamp,
616
+ archivedAt: null,
617
+ },
618
+ actorId: actor.id,
619
+ idempotencyKey: input.idempotencyKey,
620
+ auditId: deps.id(),
621
+ });
622
+ },
623
+ async save(actor, input) {
624
+ const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
625
+ if (existingId) {
626
+ const document = await getDocument(await requireVisible(actor, input.nodeId));
627
+ await deps.indexing.enqueue(existingId);
628
+ return document;
629
+ }
630
+ const node = await requireVisible(actor, input.nodeId);
631
+ if (node.kind !== "document") {
632
+ throw new IntelError(409, "document_content_required", "Only documents accept editor content versions");
633
+ }
634
+ if (!(await deps.repository.can(actor, node.id, "write"))) {
635
+ throw new IntelError(403, "node_forbidden", "This node cannot be edited");
636
+ }
637
+ if (node.currentVersionId !== input.baseVersionId) {
638
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
639
+ }
640
+ const versionId = deps.id();
641
+ const contentKey = contentKeyFor(node.id, versionId);
642
+ const createdAt = deps.now().toISOString();
643
+ const version = {
644
+ id: versionId,
645
+ nodeId: node.id,
646
+ sequence: await nextSequence(node),
647
+ contentKey,
648
+ mediaType: input.mediaType,
649
+ contentHash: await deps.hash(input.content),
650
+ size: new TextEncoder().encode(input.content).byteLength,
651
+ segment: null,
652
+ createdBy: actor.id,
653
+ createdAt,
654
+ };
655
+ await deps.content.put(contentKey, input.content, input.mediaType);
656
+ let saved;
657
+ try {
658
+ saved = await deps.repository.appendVersion({
659
+ version,
660
+ actorId: actor.id,
661
+ baseVersionId: input.baseVersionId,
662
+ idempotencyKey: input.idempotencyKey,
663
+ auditId: deps.id(),
664
+ });
665
+ }
666
+ catch (error) {
667
+ await deps.content.delete(contentKey).catch(() => undefined);
668
+ throw error;
669
+ }
670
+ if (saved === "conflict") {
671
+ await deps.content.delete(contentKey);
672
+ const replayed = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
673
+ if (replayed)
674
+ return await getDocument(await requireVisible(actor, node.id));
675
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
676
+ }
677
+ const updated = await requireVisible(actor, node.id);
678
+ await reconcileTextLinks(actor, node.id, input.mediaType, input.content);
679
+ await deps.indexing.enqueue(version.id);
680
+ return { node: updated, version, content: input.content };
681
+ },
682
+ async getAgent(actor, input) {
683
+ return await agentOf(await requireVisible(actor, input.nodeId));
684
+ },
685
+ async listAgents(actor, input) {
686
+ return { items: await deps.repository.listVisibleAgents(actor, input) };
687
+ },
688
+ /**
689
+ * The Gate Application, the node, and its first definition, in that order.
690
+ *
691
+ * ⚠️ Two writes behind one call, so the replay has to be answered for both. The node
692
+ * carries the caller's key under `node.create`; the definition carries a derived one under
693
+ * `node.save`, because the two operations share an idempotency table and the same key twice
694
+ * would make the second write look like a replay of the first. A caller who repeats the
695
+ * request gets the agent that already exists rather than a second one beside it.
696
+ *
697
+ * ⚠️ A REPLAY MAKES NO SECOND APPLICATION and answers `applicationKey: null`. It still returns
698
+ * the agent that exists — that promise is not weakened — but a repeat that minted a fresh
699
+ * principal to fill the field would leave the installation with two machine accounts for one
700
+ * agent, one of which nobody would ever switch off. Gate is not asked at all on that path.
701
+ *
702
+ * ⚠️ Gate is asked BEFORE anything is written, and that ordering IS the answer to "what if Gate
703
+ * is down". Nothing exists yet at that moment, so a Gate that does not answer leaves no node, no
704
+ * version, no R2 object and no audit event — the agent simply does not come into being, and the
705
+ * caller repeats the request. The alternative, a node that exists without a principal plus a
706
+ * way to fill it in later, buys nothing: such an agent starts no run (`agent_key_missing` in the
707
+ * runtime), so it would be a broken row waiting for a second, easily forgotten step — and
708
+ * "somebody must remember to do it in Gate" is the exact chore this ticket removes.
709
+ *
710
+ * ⚠️ If the node write fails after Gate succeeded, the fresh Application is switched off again,
711
+ * best effort. A live principal nobody references is precisely the invisible access #182 exists
712
+ * to prevent, and disabling rather than deleting keeps the compensation consistent with what
713
+ * archiving does — Gate's `DELETE` would take the identity with it (`anchrd/gate#224`).
714
+ */
715
+ async createAgent(actor, input, caller) {
716
+ // Before anything else — before the idempotency read, before Gate (#190). An installation
717
+ // without a runtime refuses the agent by name, and the refusal costs no storage read, mints
718
+ // no principal, and is the same on every surface because it lives here.
719
+ if (!deps.agentRuntimeAvailable()) {
720
+ throw new IntelError(503, "agent_runtime_not_configured", "This installation has no agent runtime, so agents cannot be created");
721
+ }
722
+ const definitionKey = `${input.idempotencyKey}:definition`;
723
+ const existingId = await deps.repository.findIdempotentNode(actor.id, "node.create", input.idempotencyKey);
724
+ if (existingId) {
725
+ return {
726
+ ...(await agentOf(await requireVisible(actor, existingId))),
727
+ applicationKey: null,
728
+ };
729
+ }
730
+ if (input.parentId !== null && !(await deps.repository.can(actor, input.parentId, "write"))) {
731
+ throw new IntelError(403, "node_forbidden", "Parent folder cannot be edited");
732
+ }
733
+ const nodeId = deps.id();
734
+ // The Application's name is what a person reads in Gate's list, so it has to be enough to
735
+ // recognise the agent by. Title alone would leave two agents called "Research" indis-
736
+ // tinguishable; the node ID is what the runtime's key map is keyed by anyway.
737
+ const application = await deps.applications.create({
738
+ token: caller.token,
739
+ name: `Intel agent ${input.title} (${nodeId})`,
740
+ });
741
+ const timestamp = deps.now().toISOString();
742
+ let node;
743
+ try {
744
+ node = await deps.repository.insertNode({
745
+ node: {
746
+ id: nodeId,
747
+ parentId: input.parentId,
748
+ kind: "agent",
749
+ title: input.title,
750
+ description: input.description,
751
+ ownerId: actor.id,
752
+ currentVersionId: null,
753
+ createdAt: timestamp,
754
+ updatedAt: timestamp,
755
+ archivedAt: null,
756
+ },
757
+ actorId: actor.id,
758
+ idempotencyKey: input.idempotencyKey,
759
+ auditId: deps.id(),
760
+ application: { id: application.id },
761
+ });
762
+ }
763
+ catch (error) {
764
+ await deps.applications
765
+ .setEnabled({ token: caller.token, applicationId: application.id, enabled: false })
766
+ .catch(() => undefined);
767
+ throw error;
768
+ }
769
+ const agent = await writeAgentVersion(actor, node, input.definition, null, definitionKey);
770
+ return {
771
+ ...agent,
772
+ // ⚠️ The one place a credential is put into an answer, and it is assembled here rather than
773
+ // carried around: `application.key` has been in one local variable since Gate returned it
774
+ // and reaches no store, no audit row and no log on the way (D27).
775
+ applicationKey: {
776
+ agentId: node.id,
777
+ applicationId: application.id,
778
+ key: application.key,
779
+ notice: "This key is shown once. Add it to the agent runtime's AGENT_APPLICATION_KEYS secret as { agentId, key } — the whole list, not just this entry — and store it nowhere else. Intel keeps only the application ID; Gate keeps only a hash.",
780
+ },
781
+ };
782
+ },
783
+ async saveAgentDefinition(actor, input) {
784
+ const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
785
+ if (existingId)
786
+ return await agentOf(await requireVisible(actor, input.nodeId));
787
+ const node = await requireVisible(actor, input.nodeId);
788
+ if (node.kind !== "agent") {
789
+ throw new IntelError(409, "not_an_agent", "Only agents accept a definition");
790
+ }
791
+ if (!(await deps.repository.can(actor, node.id, "write"))) {
792
+ throw new IntelError(403, "node_forbidden", "This agent cannot be edited");
793
+ }
794
+ if (node.currentVersionId !== input.baseVersionId) {
795
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
796
+ }
797
+ return await writeAgentVersion(actor, node, input.definition, input.baseVersionId, input.idempotencyKey);
798
+ },
799
+ async saveAttachment(actor, input) {
800
+ const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
801
+ if (existingId) {
802
+ const document = await getDocument(await requireVisible(actor, input.nodeId));
803
+ await deps.indexing.enqueue(existingId);
804
+ return document;
805
+ }
806
+ const node = await requireVisible(actor, input.nodeId);
807
+ if (node.kind !== "attachment") {
808
+ throw new IntelError(409, "not_an_attachment", "Only attachment nodes accept file uploads");
809
+ }
810
+ if (!(await deps.repository.can(actor, node.id, "write"))) {
811
+ throw new IntelError(403, "node_forbidden", "Attachment cannot be edited");
812
+ }
813
+ if (node.currentVersionId !== input.baseVersionId) {
814
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
815
+ }
816
+ const bytes = decodeBase64(input.contentBase64);
817
+ const versionId = deps.id();
818
+ const contentKey = contentKeyFor(node.id, versionId);
819
+ const createdAt = deps.now().toISOString();
820
+ const version = {
821
+ id: versionId,
822
+ nodeId: node.id,
823
+ sequence: await nextSequence(node),
824
+ contentKey,
825
+ mediaType: input.mediaType,
826
+ contentHash: await deps.hash(bytes),
827
+ size: bytes.byteLength,
828
+ segment: null,
829
+ createdBy: actor.id,
830
+ createdAt,
831
+ };
832
+ await deps.content.putBytes(contentKey, bytes.buffer, input.mediaType);
833
+ let saved;
834
+ try {
835
+ saved = await deps.repository.appendVersion({
836
+ version,
837
+ actorId: actor.id,
838
+ baseVersionId: input.baseVersionId,
839
+ idempotencyKey: input.idempotencyKey,
840
+ auditId: deps.id(),
841
+ });
842
+ }
843
+ catch (error) {
844
+ await deps.content.delete(contentKey).catch(() => undefined);
845
+ throw error;
846
+ }
847
+ if (saved === "conflict") {
848
+ await deps.content.delete(contentKey);
849
+ throw new IntelError(409, "version_conflict", "A newer version already exists");
850
+ }
851
+ await deps.indexing.enqueue(version.id);
852
+ return { node: await requireVisible(actor, node.id), version, content: null };
853
+ },
854
+ async getAttachment(actor, nodeId) {
855
+ return await attachment(actor, nodeId);
856
+ },
857
+ async readAttachment(actor, nodeId) {
858
+ const metadata = await attachment(actor, nodeId);
859
+ const body = await deps.content.getStream(metadata.version.contentKey);
860
+ if (body === null)
861
+ throw new IntelError(500, "content_missing", "Attachment is missing");
862
+ return { attachment: metadata, body };
863
+ },
864
+ async getTable(actor, nodeId) {
865
+ const node = await requireVisible(actor, nodeId);
866
+ if (node.kind !== "table") {
867
+ throw new IntelError(404, "table_not_found", "Table was not found");
868
+ }
869
+ return await tableOf(node);
870
+ },
871
+ /**
872
+ * Writes the header, once. The columns are the contract every later append is measured against
873
+ * (#40), so a second definition is refused rather than merged: a table whose header changed
874
+ * would reinterpret every row already appended under the old one, silently and irreversibly.
875
+ */
876
+ async defineTable(actor, input) {
877
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.append", input.idempotencyKey);
878
+ const node = await requireTable(actor, input.nodeId);
879
+ if (replayedId)
880
+ return await tableOf(node);
881
+ if (node.currentVersionId !== null) {
882
+ throw new IntelError(409, "table_already_defined", "This table already has a header");
883
+ }
884
+ // The definition is the first snapshot (#135): the complete state of a table with no rows
885
+ // yet, and the anchor "read from the newest snapshot" starts from.
886
+ const body = encodeCsv([input.columns]);
887
+ const version = await writeTableSegment(actor, node, body, "snapshot", input.idempotencyKey);
888
+ await deps.indexing.enqueue(version.id);
889
+ return await tableOf({ ...node, currentVersionId: version.id });
890
+ },
891
+ /**
892
+ * Rows at the end, and nothing else touched.
893
+ *
894
+ * ⚠️ No `baseVersionId` and no conflict: each append writes its own immutable object and its
895
+ * own version row, so two appends that arrive together both land and neither can overwrite the
896
+ * other. This is the whole difference to `save`, which replaces content and therefore has to
897
+ * know what it replaces.
898
+ */
899
+ async appendTableRows(actor, input) {
900
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.append", input.idempotencyKey);
901
+ const node = await requireTable(actor, input.nodeId);
902
+ if (replayedId) {
903
+ const replayed = await deps.repository.getVersion(replayedId);
904
+ if (replayed) {
905
+ return {
906
+ node: await requireVisible(actor, node.id),
907
+ version: replayed,
908
+ appended: input.rows.length,
909
+ };
910
+ }
911
+ }
912
+ const header = await tableHeader(node);
913
+ if (header === null) {
914
+ throw new IntelError(409, "table_undefined", "This table has no header yet; define its columns before appending");
915
+ }
916
+ // ⚠️ Refused, never padded and never truncated — see `requireRowShape`.
917
+ requireRowShape(input.rows, header);
918
+ const version = await writeTableSegment(actor, node, encodeCsv(input.rows), "append", input.idempotencyKey);
919
+ await deps.indexing.enqueue(version.id);
920
+ return {
921
+ node: await requireVisible(actor, node.id),
922
+ version,
923
+ appended: input.rows.length,
924
+ };
925
+ },
926
+ /**
927
+ * Rows replaced in place (#135). Position is the address — rows carry no identity on purpose —
928
+ * so the write is guarded the way `save` guards a document: against the version the caller
929
+ * read, and a table that moved on answers `version_conflict` rather than editing rows the
930
+ * positions no longer mean.
931
+ */
932
+ async updateTableRows(actor, input) {
933
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.table_update", input.idempotencyKey);
934
+ const node = await requireTable(actor, input.nodeId);
935
+ if (replayedId) {
936
+ const replayed = await deps.repository.getVersion(replayedId);
937
+ if (replayed) {
938
+ return {
939
+ node: await requireVisible(actor, node.id),
940
+ version: replayed,
941
+ updated: input.updates.length,
942
+ };
943
+ }
944
+ }
945
+ const { header, rows } = await tableStateFor(node, input.baseVersionId);
946
+ requireRowShape(input.updates.map((update) => update.row), header);
947
+ requirePositions(input.updates.map((update) => update.position), rows.length);
948
+ for (const update of input.updates)
949
+ rows[update.position] = [...update.row];
950
+ const version = await writeTableSnapshot({
951
+ actor,
952
+ node,
953
+ body: encodeCsv([header, ...rows]),
954
+ baseVersionId: input.baseVersionId,
955
+ operation: "node.table_update",
956
+ metadata: {
957
+ positions: input.updates.map((update) => update.position),
958
+ updated: input.updates.length,
959
+ },
960
+ idempotencyKey: input.idempotencyKey,
961
+ });
962
+ return {
963
+ node: await requireVisible(actor, node.id),
964
+ version,
965
+ updated: input.updates.length,
966
+ };
967
+ },
968
+ async deleteTableRows(actor, input) {
969
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.table_delete", input.idempotencyKey);
970
+ const node = await requireTable(actor, input.nodeId);
971
+ if (replayedId) {
972
+ const replayed = await deps.repository.getVersion(replayedId);
973
+ if (replayed) {
974
+ return {
975
+ node: await requireVisible(actor, node.id),
976
+ version: replayed,
977
+ deleted: input.positions.length,
978
+ };
979
+ }
980
+ }
981
+ const { header, rows } = await tableStateFor(node, input.baseVersionId);
982
+ requirePositions(input.positions, rows.length);
983
+ const removed = new Set(input.positions);
984
+ const remaining = rows.filter((_row, position) => !removed.has(position));
985
+ const version = await writeTableSnapshot({
986
+ actor,
987
+ node,
988
+ body: encodeCsv([header, ...remaining]),
989
+ baseVersionId: input.baseVersionId,
990
+ operation: "node.table_delete",
991
+ metadata: { positions: input.positions, deleted: input.positions.length },
992
+ idempotencyKey: input.idempotencyKey,
993
+ });
994
+ return {
995
+ node: await requireVisible(actor, node.id),
996
+ version,
997
+ deleted: input.positions.length,
998
+ };
999
+ },
1000
+ /**
1001
+ * A new header over the stored rows, through the explicit mapping and only through it (#135).
1002
+ * The blind second definition stays refused in `defineTable` — this is the deliberate opposite:
1003
+ * every new column names the current column that fills it, or names none and starts empty, and
1004
+ * a current column no entry names is removed together with its cells.
1005
+ */
1006
+ async redefineTable(actor, input) {
1007
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.table_redefine", input.idempotencyKey);
1008
+ const node = await requireTable(actor, input.nodeId);
1009
+ if (replayedId)
1010
+ return await tableOf(node);
1011
+ const { header, rows } = await tableStateFor(node, input.baseVersionId);
1012
+ // ⚠️ Sources are matched against the header as it is stored, exactly. A forgiving match would
1013
+ // make "which column did this take" depend on rules nobody can read off the table.
1014
+ const sourceIndex = new Map(header.map((column, index) => [column, index]));
1015
+ for (const column of input.columns) {
1016
+ if (column.source !== null && !sourceIndex.has(column.source)) {
1017
+ throw new IntelError(400, "table_column_unknown", `Column ${column.source} does not exist: the table has ${header.join(", ")}`);
1018
+ }
1019
+ }
1020
+ const remapped = rows.map((row) => input.columns.map((column) => {
1021
+ if (column.source === null)
1022
+ return "";
1023
+ const index = sourceIndex.get(column.source);
1024
+ return index === undefined ? "" : (row[index] ?? "");
1025
+ }));
1026
+ await writeTableSnapshot({
1027
+ actor,
1028
+ node,
1029
+ body: encodeCsv([input.columns.map((column) => column.name), ...remapped]),
1030
+ baseVersionId: input.baseVersionId,
1031
+ operation: "node.table_redefine",
1032
+ metadata: { columns: input.columns },
1033
+ idempotencyKey: input.idempotencyKey,
1034
+ });
1035
+ return await tableOf(await requireVisible(actor, node.id));
1036
+ },
1037
+ async listVersions(actor, nodeId) {
1038
+ await requireVisible(actor, nodeId);
1039
+ return { items: await deps.repository.listVersions(nodeId) };
1040
+ },
1041
+ async update(actor, input) {
1042
+ const current = await requireVisible(actor, input.nodeId);
1043
+ if (!(await deps.repository.can(actor, current.id, "write"))) {
1044
+ throw new IntelError(403, "node_forbidden", "This node cannot be edited");
1045
+ }
1046
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.update", input.idempotencyKey);
1047
+ if (replayedId)
1048
+ return await requireVisible(actor, replayedId);
1049
+ if (input.parentId !== undefined && input.parentId !== null) {
1050
+ if (input.parentId === current.id) {
1051
+ throw new IntelError(409, "move_cycle", "A node cannot contain itself");
1052
+ }
1053
+ const parent = await requireVisible(actor, input.parentId);
1054
+ if (parent.kind !== "folder") {
1055
+ throw new IntelError(409, "parent_not_folder", "A node's parent must be a folder");
1056
+ }
1057
+ if (!(await deps.repository.can(actor, parent.id, "write"))) {
1058
+ throw new IntelError(403, "node_forbidden", "Destination folder cannot be edited");
1059
+ }
1060
+ }
1061
+ const updatedAt = deps.now().toISOString();
1062
+ const updated = await deps.repository.updateNode({
1063
+ node: {
1064
+ ...current,
1065
+ parentId: input.parentId === undefined ? current.parentId : input.parentId,
1066
+ title: input.title ?? current.title,
1067
+ description: input.description === undefined ? current.description : input.description,
1068
+ updatedAt,
1069
+ },
1070
+ baseUpdatedAt: input.baseUpdatedAt,
1071
+ actorId: actor.id,
1072
+ idempotencyKey: input.idempotencyKey,
1073
+ auditId: deps.id(),
1074
+ });
1075
+ if (updated === "cycle") {
1076
+ throw new IntelError(409, "move_cycle", "A node cannot be moved into its descendant");
1077
+ }
1078
+ if (updated === "conflict") {
1079
+ throw new IntelError(409, "update_conflict", "This node was changed by another editor");
1080
+ }
1081
+ if (updated.currentVersionId)
1082
+ await deps.indexing.enqueue(updated.currentVersionId);
1083
+ return updated;
1084
+ },
1085
+ /**
1086
+ * Archiving and restoring — and for an agent, its Gate Application with it (#182).
1087
+ *
1088
+ * ⚠️ Gate is switched BEFORE the node row, and the order is the whole safety argument. The state
1089
+ * that must not exist is "archived agent, live principal": a machine account that still holds
1090
+ * every grant the agent had, on a node nobody looks at any more. Writing the row first and then
1091
+ * failing at Gate produces exactly that. The reverse leaves "live agent, switched-off
1092
+ * principal" — the agent refuses its next run by name (`agent_principal_rejected` in the
1093
+ * runtime), which somebody notices the same day and which repeating the call repairs.
1094
+ *
1095
+ * ⚠️ The optimistic check is made twice on purpose: cheaply here so a stale `baseUpdatedAt`
1096
+ * never reaches Gate, and authoritatively in the statement that writes. Only a genuine race
1097
+ * gets past the first, and that path puts Gate back the way it was before refusing.
1098
+ *
1099
+ * ⚠️ Gate is asked even when the node is already in the requested state. Both routes are
1100
+ * idempotent there, and it is what makes a repeated call heal a run that died between the two
1101
+ * writes — the alternative would be an agent stuck live because its node already said archived.
1102
+ *
1103
+ * ⚠️ Nothing here is skipped for an agent WITHOUT an Application. There is no principal to
1104
+ * switch, Gate is not called, and the node archives like a document — the case of an agent
1105
+ * imported, restored from a bundle, or written before #182.
1106
+ */
1107
+ async archive(actor, input, caller) {
1108
+ const current = await requireVisible(actor, input.nodeId);
1109
+ if (!(await deps.repository.can(actor, current.id, "write"))) {
1110
+ throw new IntelError(403, "node_forbidden", "This node cannot be edited");
1111
+ }
1112
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.archive", input.idempotencyKey);
1113
+ // A replay switches nothing a second time: the first run already did, and Gate must not learn
1114
+ // about a request that is not happening.
1115
+ if (replayedId)
1116
+ return await requireVisible(actor, replayedId);
1117
+ const applicationId = current.kind === "agent" ? await deps.repository.agentApplicationId(current.id) : null;
1118
+ if (applicationId !== null) {
1119
+ if (current.updatedAt !== input.baseUpdatedAt) {
1120
+ throw new IntelError(409, "update_conflict", "This node was changed by another editor");
1121
+ }
1122
+ await deps.applications.setEnabled({
1123
+ token: caller.token,
1124
+ applicationId,
1125
+ enabled: !input.archived,
1126
+ });
1127
+ }
1128
+ const updatedAt = deps.now().toISOString();
1129
+ const updated = await deps.repository.archiveNode({
1130
+ nodeId: current.id,
1131
+ baseUpdatedAt: input.baseUpdatedAt,
1132
+ archivedAt: input.archived ? updatedAt : null,
1133
+ updatedAt,
1134
+ actorId: actor.id,
1135
+ idempotencyKey: input.idempotencyKey,
1136
+ auditId: deps.id(),
1137
+ });
1138
+ if (updated === "conflict") {
1139
+ if (applicationId !== null) {
1140
+ // Back to what the node actually said, not to the inverse of what was asked: repeating an
1141
+ // archive on an already archived agent would otherwise switch its principal back ON.
1142
+ await deps.applications
1143
+ .setEnabled({
1144
+ token: caller.token,
1145
+ applicationId,
1146
+ enabled: current.archivedAt === null,
1147
+ })
1148
+ .catch(() => undefined);
1149
+ }
1150
+ throw new IntelError(409, "update_conflict", "This node was changed by another editor");
1151
+ }
1152
+ return updated;
1153
+ },
1154
+ async listGrants(actor, resourceId) {
1155
+ const node = await requireVisible(actor, resourceId);
1156
+ if (!(await deps.repository.can(actor, resourceId, "share"))) {
1157
+ throw new IntelError(403, "node_forbidden", "Sharing of this node cannot be managed");
1158
+ }
1159
+ return {
1160
+ resourceId: node.id,
1161
+ applicableVerbs: applicableVerbs(node.kind),
1162
+ items: await deps.repository.listGrants(resourceId),
1163
+ };
1164
+ },
1165
+ async listLinks(actor, nodeId) {
1166
+ await requireVisible(actor, nodeId);
1167
+ return { items: await deps.repository.listLinksVisible(actor, nodeId) };
1168
+ },
1169
+ /**
1170
+ * The titles of linked documents, for the reader who is looking at the text (#41).
1171
+ *
1172
+ * ⚠️ The one place a document link gets a name, and it answers with what this reader may see —
1173
+ * `getVisible`, the same predicate every other node read goes through — never with a
1174
+ * lookup written specially for a label. What is missing from the answer is missing for two
1175
+ * reasons that must stay indistinguishable: the target is gone, or it is not theirs to see. A
1176
+ * shape that told them apart would let a document confirm the existence of one they may not
1177
+ * reach, and that is what the link does not get to say.
1178
+ *
1179
+ * ⚠️ Archived targets are absent too. A link to a deleted document has to break visibly rather
1180
+ * than point quietly at nothing, and "absent" is what the reader's side draws as broken.
1181
+ */
1182
+ async resolveLinks(actor, input) {
1183
+ return { items: await deps.repository.resolveVisibleTitles(actor, input.nodeIds) };
1184
+ },
1185
+ async graph(actor, input) {
1186
+ return await deps.repository.graphVisible(actor, input);
1187
+ },
1188
+ // ⚠️ The one shape of this question that answers `null` instead of throwing, and the only one a
1189
+ // graph may be built from. `get` loads the body and turns a refusal into a 404 the caller has to
1190
+ // catch; here "you cannot reach it" is a value, so a drawing can leave a node out rather than
1191
+ // deciding what to do with an exception halfway through (#19).
1192
+ //
1193
+ // ⚠️ Visibility and nothing else — deliberately not "and not archived". A run authorizes its
1194
+ // tree links through this same answer (#20), and archiving a document must not start
1195
+ // refusing steps under a message that names the wrong reason. Callers that must not *draw* an
1196
+ // archived node say so where they draw, the way the callee side already does.
1197
+ async visibleNode(actor, nodeId) {
1198
+ return await deps.repository.getVisible(actor, nodeId);
1199
+ },
1200
+ async share(actor, input) {
1201
+ const node = await requireVisible(actor, input.resourceId);
1202
+ if (!(await deps.repository.can(actor, input.resourceId, "share"))) {
1203
+ throw new IntelError(403, "node_forbidden", "Sharing of this node cannot be managed");
1204
+ }
1205
+ if (!applicableVerbs(node.kind).includes(input.verb)) {
1206
+ throw new IntelError(409, "verb_not_applicable", `A ${node.kind} cannot be granted ${input.verb}`);
1207
+ }
1208
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.share", input.idempotencyKey);
1209
+ const principal = input.principal.type === "email"
1210
+ ? { type: "email", email: input.principal.email.toLowerCase() }
1211
+ : input.principal;
1212
+ if (replayedId) {
1213
+ const replayed = (await deps.repository.listGrants(input.resourceId)).find((grant) => grant.id === replayedId);
1214
+ // A replay describes the same access as the first attempt did, so the warning is asked
1215
+ // again rather than remembered: whether a document is readable can have changed since.
1216
+ if (replayed) {
1217
+ return {
1218
+ grant: replayed,
1219
+ unreadable: await unreadableForPrincipal(actor, node.id, replayed.principal),
1220
+ };
1221
+ }
1222
+ }
1223
+ const timestamp = deps.now().toISOString();
1224
+ const grant = await deps.repository.setGrant({
1225
+ grant: {
1226
+ id: deps.id(),
1227
+ resourceId: input.resourceId,
1228
+ principal,
1229
+ verb: input.verb,
1230
+ expiresAt: input.expiresAt,
1231
+ createdBy: actor.id,
1232
+ createdAt: timestamp,
1233
+ },
1234
+ actorId: actor.id,
1235
+ idempotencyKey: input.idempotencyKey,
1236
+ auditId: deps.id(),
1237
+ });
1238
+ // ⚠️ After the grant is written, never before. The answer has to describe the access that is
1239
+ // now in force — sharing `read` on this folder is exactly what makes the documents inside it
1240
+ // readable, and a warning computed a moment earlier would name them all.
1241
+ return { grant, unreadable: await unreadableForPrincipal(actor, node.id, principal) };
1242
+ },
1243
+ async revokeGrant(actor, input) {
1244
+ await requireVisible(actor, input.resourceId);
1245
+ if (!(await deps.repository.can(actor, input.resourceId, "share"))) {
1246
+ throw new IntelError(403, "node_forbidden", "Sharing of this node cannot be managed");
1247
+ }
1248
+ const replayed = await deps.repository.findIdempotentRevocation(actor.id, input.idempotencyKey);
1249
+ if (replayed !== null)
1250
+ return { revoked: replayed };
1251
+ // ⚠️ What makes a folder a library is `execute` for the whole organization: that is the one
1252
+ // grant ADR-0004 §3 lets a flow call across a folder edge for. Taking it away while calls
1253
+ // reach in from outside would leave those flows published and unrunnable, so the attempt names
1254
+ // the callers instead of breaking them silently. A grant to one principal beside it narrows
1255
+ // nothing while the organization-wide one stands, so only that one is guarded — and only when
1256
+ // no second organization-wide `execute` above it keeps the reach alive, because then this
1257
+ // revocation narrows nothing either and there is nothing to refuse.
1258
+ const revoked = (await deps.repository.listGrants(input.resourceId)).find((grant) => grant.id === input.grantId);
1259
+ if (revoked?.verb === "execute" &&
1260
+ revoked.principal.type === "organization" &&
1261
+ !(await deps.repository.organizationExecuteReaches(input.resourceId, input.grantId))) {
1262
+ const callers = await deps.externalFlowCallers(actor, input.resourceId);
1263
+ if (callers.visible.length || callers.hidden) {
1264
+ throw new IntelError(409, "folder_execute_in_use", callersDetail(callers));
1265
+ }
1266
+ }
1267
+ return {
1268
+ revoked: await deps.repository.revokeGrant({
1269
+ resourceId: input.resourceId,
1270
+ grantId: input.grantId,
1271
+ actorId: actor.id,
1272
+ idempotencyKey: input.idempotencyKey,
1273
+ auditId: deps.id(),
1274
+ occurredAt: deps.now().toISOString(),
1275
+ }),
1276
+ };
1277
+ },
1278
+ async search(actor, input) {
1279
+ // Authorization before access (ADR-0004 §2): a scope is resolved against the tree and its ACLs
1280
+ // before FTS, the vector index, or any content is touched. A folder the actor may not read and
1281
+ // one that does not exist give the same answer on purpose — `getVisible` cannot tell them
1282
+ // apart, so a scope cannot be used to probe what is filed elsewhere.
1283
+ if (input.scopeId !== undefined) {
1284
+ const scope = await deps.repository.getVisible(actor, input.scopeId);
1285
+ if (!scope || scope.archivedAt) {
1286
+ throw new IntelError(404, "scope_not_found", "Search scope was not found");
1287
+ }
1288
+ if (scope.kind !== "folder") {
1289
+ throw new IntelError(400, "scope_not_folder", "A search scope must be a folder");
1290
+ }
1291
+ }
1292
+ const lexical = await deps.repository.searchVisible(actor, {
1293
+ ...input,
1294
+ limit: Math.min(50, input.limit * 2),
1295
+ });
1296
+ if (!deps.semantic)
1297
+ return { items: lexical.slice(0, input.limit) };
1298
+ try {
1299
+ // ⚠️ The scope narrows the vector hits afterwards, in the same D1 statement that already
1300
+ // re-checks the ACL — so the candidate set has to be wide enough for that cut to leave
1301
+ // something. A folder holding a dozen documents inside a tree of thousands is not reached by
1302
+ // the fan-out an unscoped search gets away with, and a starved scope looks like an empty
1303
+ // folder. 100 is what the port clamps to, so a scoped search asks for all there is.
1304
+ const candidates = input.scopeId === undefined ? Math.min(100, input.limit * 4) : 100;
1305
+ const hits = await deps.semantic.search(input.query, candidates);
1306
+ const semanticScores = new Map();
1307
+ for (const hit of hits) {
1308
+ semanticScores.set(hit.nodeId, Math.max(semanticScores.get(hit.nodeId) ?? 0, hit.score));
1309
+ }
1310
+ const semantic = await deps.repository.hydrateVisibleCitations(actor, [...semanticScores.keys()], input.scopeId);
1311
+ return {
1312
+ items: mergeSearchResults(lexical, semantic, semanticScores, input.limit),
1313
+ };
1314
+ }
1315
+ catch {
1316
+ return { items: lexical.slice(0, input.limit) };
1317
+ }
1318
+ },
1319
+ async reindex(actor) {
1320
+ if (actor.isAdmin !== true) {
1321
+ throw new IntelError(403, "reindex_forbidden", "Reindex permission is required");
1322
+ }
1323
+ let queued = 0;
1324
+ let after = null;
1325
+ for (;;) {
1326
+ const versionIds = await deps.repository.listCurrentVersionIds({ after, limit: 100 });
1327
+ for (const versionId of versionIds)
1328
+ await deps.indexing.enqueue(versionId);
1329
+ queued += versionIds.length;
1330
+ if (versionIds.length < 100)
1331
+ break;
1332
+ after = versionIds.at(-1) ?? null;
1333
+ }
1334
+ return { queued };
1335
+ },
1336
+ };
1337
+ }