@anchrd/intel-api 0.13.0 → 0.15.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 (57) hide show
  1. package/dist/adapters/cloudflare/cloudflare.js +1 -68
  2. package/dist/adapters/cloudflare/cloudflare.types.d.ts +0 -39
  3. package/dist/adapters/db/db-flows.js +1 -1
  4. package/dist/adapters/db/db-grants.js +1 -1
  5. package/dist/adapters/db/db-indexing.js +79 -0
  6. package/dist/adapters/db/db.js +81 -139
  7. package/dist/adapters/semantic-index/semantic-index.js +97 -17
  8. package/dist/adapters/semantic-index/semantic-index.types.d.ts +20 -1
  9. package/dist/bundle/bundle.js +42 -134
  10. package/dist/cli/cli.js +3 -9
  11. package/dist/http/http.js +5 -206
  12. package/dist/http/http.types.d.ts +0 -8
  13. package/dist/indexing/indexing.js +133 -55
  14. package/dist/indexing/indexing.types.d.ts +1 -0
  15. package/dist/intel/intel.js +4 -9
  16. package/dist/intel/intel.types.d.ts +0 -6
  17. package/dist/mcp/mcp.js +33 -308
  18. package/dist/mcp/mcp.types.d.ts +2 -7
  19. package/dist/nodes/document-links/document-links.d.ts +6 -8
  20. package/dist/nodes/document-links/document-links.js +8 -31
  21. package/dist/nodes/nodes.js +92 -826
  22. package/dist/nodes/nodes.types.d.ts +57 -158
  23. package/dist/tools/tools.js +37 -148
  24. package/dist/tools/tools.types.d.ts +0 -21
  25. package/migrations/0009_no_context_policy.sql +15 -0
  26. package/migrations/0017_a_vector_per_card.sql +38 -0
  27. package/migrations/0018_no_context_policy_at_last.sql +97 -0
  28. package/migrations/0019_one_name_for_the_grants.sql +52 -0
  29. package/package.json +2 -2
  30. package/dist/adapters/cloudflare-api/cloudflare-api.d.ts +0 -22
  31. package/dist/adapters/cloudflare-api/cloudflare-api.js +0 -214
  32. package/dist/adapters/cloudflare-api/cloudflare-api.types.d.ts +0 -64
  33. package/dist/adapters/cloudflare-api/cloudflare-api.types.js +0 -1
  34. package/dist/adapters/gate-applications/gate-applications.d.ts +0 -23
  35. package/dist/adapters/gate-applications/gate-applications.js +0 -88
  36. package/dist/adapters/tool-delegation/tool-delegation.d.ts +0 -22
  37. package/dist/adapters/tool-delegation/tool-delegation.js +0 -90
  38. package/dist/agent-costs/agent-costs.d.ts +0 -16
  39. package/dist/agent-costs/agent-costs.js +0 -105
  40. package/dist/agent-costs/agent-costs.types.d.ts +0 -30
  41. package/dist/agent-costs/agent-costs.types.js +0 -1
  42. package/dist/agent-runtime/agent-runtime.d.ts +0 -16
  43. package/dist/agent-runtime/agent-runtime.js +0 -150
  44. package/dist/agent-runtime/agent-runtime.types.d.ts +0 -122
  45. package/dist/agent-runtime/agent-runtime.types.js +0 -1
  46. package/dist/model-catalog/model-catalog.d.ts +0 -2
  47. package/dist/model-catalog/model-catalog.js +0 -99
  48. package/dist/model-catalog/model-catalog.types.d.ts +0 -15
  49. package/dist/model-catalog/model-catalog.types.js +0 -1
  50. package/dist/nodes/board/board.d.ts +0 -59
  51. package/dist/nodes/board/board.js +0 -528
  52. package/dist/nodes/board/board.types.d.ts +0 -31
  53. package/dist/nodes/board/board.types.js +0 -1
  54. package/migrations/0013_agents_in_the_tree.sql +0 -76
  55. package/migrations/0014_agent_applications.sql +0 -25
  56. package/migrations/0015_tools_delegated_from_a_connection.sql +0 -15
  57. package/migrations/0016_boards_in_the_tree.sql +0 -80
@@ -1,8 +1,7 @@
1
- import { AgentDefinition, AgentMediaType, agentReferenceAccepts, BoardDocument, BoardMediaType, TableMediaType, } from "@anchrd/intel-contract";
1
+ import { TableMediaType, } from "@anchrd/intel-contract";
2
2
  import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
3
3
  import { IntelError } from "../shared/intel-error/intel-error.js";
4
4
  import { plainTitle } from "../shared/plain-title/plain-title.js";
5
- import { createBoard, upgradeStoredBoard } from "./board/board.js";
6
5
  import { documentLinkTargets } from "./document-links/document-links.js";
7
6
  // ⚠️ The R2 key of a version written before #125 begins `knowledge/`, and it stays that way. A key
8
7
  // is stored in `node_versions.content_key` and read back from there; nothing derives one from ids,
@@ -14,16 +13,12 @@ function contentKeyFor(nodeId, versionId) {
14
13
  // A verb that cannot apply to a node is neither offered on it nor accepted for it (ADR-0004 §2).
15
14
  // The answer lives here rather than in the screen so HTTP, MCP and the UI cannot disagree about it.
16
15
  //
17
- // `execute` is meaningful where something can be run: a folder, because only a folder can hold a
18
- // flow, and an agent, where it means being allowed to USE it (#139). Reading an agent's definition
19
- // and being permitted to put it to work are separate questions the whole reason the verbs are
20
- // granted independently rather than as a ladder and an agent is the case that makes the
21
- // difference obvious: its definition is inspectable by design (ADR-0005 §2), so `read` on it must
22
- // not imply the right to set it going.
16
+ // `execute` is meaningful where something can be run, and since #390 that is the folder alone
17
+ // because only a folder can hold a flow. Reading something and being permitted to put it to work
18
+ // stay separate questions, which is the whole reason the verbs are granted independently rather
19
+ // than as a ladder (ADR-0004 §2).
23
20
  function applicableVerbs(kind) {
24
- return kind === "folder" || kind === "agent"
25
- ? ["read", "write", "execute", "share"]
26
- : ["read", "write", "share"];
21
+ return kind === "folder" ? ["read", "write", "execute", "share"] : ["read", "write", "share"];
27
22
  }
28
23
  // ⚠️ The refusal has to be actionable without becoming a directory of the tree. Whoever holds
29
24
  // `share` on one folder must not learn the titles of flows they may not see, so the ones they may
@@ -47,29 +42,6 @@ function asPrincipalActor(principal) {
47
42
  return { id: "", email: principal.email };
48
43
  return { id: "", email: "" };
49
44
  }
50
- /**
51
- * A stored definition, parsed rather than trusted — the version boundary the repository rule names
52
- * (`CLAUDE.md`, "stored Flow graphs read across a version boundary").
53
- *
54
- * ⚠️ It fails loudly on purpose. Intel is the only writer of this body, so a definition that does
55
- * not parse is corruption, not an older shape to be coerced into the current one. Reading it
56
- * leniently would hand the runtime an agent that has quietly forgotten its references, and the
57
- * first sign of it would be an agent answering without the knowledge it was given.
58
- */
59
- function parseStoredDefinition(body) {
60
- let json;
61
- try {
62
- json = JSON.parse(body);
63
- }
64
- catch {
65
- throw new IntelError(500, "agent_definition_invalid", "Agent definition is not valid JSON");
66
- }
67
- const parsed = AgentDefinition.safeParse(json);
68
- if (!parsed.success) {
69
- throw new IntelError(500, "agent_definition_invalid", "Agent definition does not match the contract");
70
- }
71
- return parsed.data;
72
- }
73
45
  function decodeBase64(value) {
74
46
  if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
75
47
  throw new IntelError(400, "attachment_invalid", "Attachment content is not valid base64");
@@ -82,9 +54,6 @@ function decodeBase64(value) {
82
54
  }
83
55
  }
84
56
  export function createNodes(deps) {
85
- // Made here rather than passed in: the board's rules need nothing this service does not already
86
- // have, and a caller who could substitute them could substitute the cycle checks with them.
87
- const board = createBoard({ id: () => deps.id() });
88
57
  function mergeSearchResults(lexical, semantic, semanticScores, limit) {
89
58
  const merged = new Map();
90
59
  for (const citation of lexical) {
@@ -128,7 +97,7 @@ export function createNodes(deps) {
128
97
  * ⚠️ This is the price of appending without reading. Each `append` writes one immutable object
129
98
  * holding only the rows it added, so the write is O(new rows) and two concurrent appends cannot
130
99
  * overwrite one another — but a read of a table with k appends costs k R2 gets. That is the
131
- * trade the ticket asks for by name: the write side is the hot path an agent uses on a schedule,
100
+ * trade the ticket asks for by name: the write side is the hot path a flow uses on a schedule,
132
101
  * the read side is a person opening a grid or one indexing pass. The gets are issued together
133
102
  * rather than in a chain so the cost is k requests, not k round trips; compacting old segments
134
103
  * into one object is a later ticket, and it can happen without changing anything a caller sees
@@ -313,140 +282,6 @@ export function createNodes(deps) {
313
282
  throw new IntelError(400, "table_row_position", `Row position ${outside} is out of range: the table has ${rowCount} row${rowCount === 1 ? "" : "s"}`);
314
283
  }
315
284
  }
316
- async function agentOf(node) {
317
- if (node.kind !== "agent") {
318
- throw new IntelError(409, "not_an_agent", "This node is not an agent");
319
- }
320
- // The Application ID rides on every agent read (#182). It is a name and not a credential, so
321
- // there is nothing to withhold — and the surface that draws an agent has to be able to say
322
- // whether it has a principal at all, because an agent without one runs nothing.
323
- const applicationId = await deps.repository.agentApplicationId(node.id);
324
- if (node.currentVersionId === null) {
325
- return { node, version: null, definition: null, applicationId };
326
- }
327
- const version = await deps.repository.getVersion(node.currentVersionId);
328
- if (!version)
329
- throw new IntelError(500, "version_missing", "Current version is missing");
330
- const body = await deps.content.get(version.contentKey);
331
- if (body === null)
332
- throw new IntelError(500, "content_missing", "Version content is missing");
333
- return { node, version, definition: parseStoredDefinition(body), applicationId };
334
- }
335
- /**
336
- * Whether every reference names a node its role can actually use (#255).
337
- *
338
- * ⚠️ Here and not in the screen. The screen offers only what a role accepts, but a definition can
339
- * be written straight over the Intel MCP surface — and a `memory` reference pointing at a
340
- * document is not a cosmetic mistake: `agent_remember` writes into what it is given, so the agent
341
- * would overwrite the document somebody handed it.
342
- *
343
- * ⚠️ A reference to a node this actor cannot see is left alone, deliberately. Refusing it would
344
- * answer "there is a node with that id and it is the wrong kind" to somebody who may not know the
345
- * node exists (#41), and accepting it changes nothing: the runtime reads with the AGENT's grants,
346
- * and what it may not read it reports as an unavailable reference. What is checked is what the
347
- * writer can see, which is exactly what they chose.
348
- *
349
- * ⚠️ Ids are deduplicated before the reads. The contract allows two hundred references and the
350
- * same folder twice; one lookup per row would make a large definition pay for the repetition.
351
- */
352
- async function checkReferences(actor, definition) {
353
- const kinds = new Map();
354
- for (const reference of definition.references) {
355
- let kind = kinds.get(reference.nodeId);
356
- if (kind === undefined) {
357
- kind = (await deps.repository.getVisible(actor, reference.nodeId))?.kind ?? null;
358
- kinds.set(reference.nodeId, kind);
359
- }
360
- if (kind !== null && !agentReferenceAccepts(reference.role, kind)) {
361
- throw new IntelError(422, "agent_reference_kind_not_allowed", `A ${kind} cannot be used as ${reference.role}`);
362
- }
363
- }
364
- }
365
- /**
366
- * The one place a caller's asked-for definition becomes the definition Intel stores (D30).
367
- *
368
- * ⚠️ `delegatedBy` is ADDED here, from the session, and could not have arrived any other way:
369
- * `AgentDefinitionInput` has no field for it, so a body naming somebody else is a parse error at
370
- * the boundary rather than a value this function has to remember to ignore. Adding it here is
371
- * safe in exactly one direction — the writer must prove below that they reach every named server
372
- * themselves, so the delegation can only ever move to somebody who already had it.
373
- *
374
- * ⚠️ The check is a live `tools/list` through the portal, before anything is written. A server the
375
- * saver cannot reach is refused BY NAME — a silent drop would leave a person looking at a saved
376
- * agent that quietly has one tool fewer than they picked.
377
- */
378
- async function delegationOf(actor, definition) {
379
- const tools = definition.tools;
380
- if (!tools)
381
- return { ...definition, tools: null };
382
- const servers = [...new Set(tools.servers)];
383
- if (servers.length === 0) {
384
- return { ...definition, tools: { delegatedBy: actor.id, servers: [] } };
385
- }
386
- const reachable = new Set(await deps.toolServers(actor));
387
- const missing = servers.filter((server) => !reachable.has(server));
388
- if (missing.length > 0) {
389
- throw new IntelError(403, "tool_server_not_delegatable", `You do not reach these MCP servers, so you cannot delegate them: ${missing.join(", ")}`);
390
- }
391
- return { ...definition, tools: { delegatedBy: actor.id, servers } };
392
- }
393
- /**
394
- * One immutable definition version, written exactly the way a document's content is (ADR-0005 §1).
395
- *
396
- * ⚠️ R2 object first, version row second, R2 object deleted again if the row does not land — the
397
- * same order `save` keeps and for the same reason: an orphaned object is invisible, while a
398
- * version row pointing at nothing is an agent that cannot be read at all.
399
- *
400
- * ⚠️ The body is serialized from the PARSED definition, never from the caller's JSON text. What
401
- * is stored is therefore always what the contract accepted, and a field the strict schema refused
402
- * cannot reach R2 by riding along in the original string.
403
- *
404
- * ⚠️ `segment` is `null`, like a document's and unlike a table's (#135): every version here holds
405
- * the whole definition, so there is nothing for a snapshot to mark off from what came before it.
406
- */
407
- async function writeAgentVersion(actor, node, definition, baseVersionId, idempotencyKey) {
408
- const body = JSON.stringify(definition);
409
- const versionId = deps.id();
410
- const contentKey = contentKeyFor(node.id, versionId);
411
- const version = {
412
- id: versionId,
413
- nodeId: node.id,
414
- sequence: await nextSequence(node),
415
- contentKey,
416
- mediaType: AgentMediaType,
417
- contentHash: await deps.hash(body),
418
- size: new TextEncoder().encode(body).byteLength,
419
- segment: null,
420
- createdBy: actor.id,
421
- createdAt: deps.now().toISOString(),
422
- };
423
- await deps.content.put(contentKey, body, AgentMediaType);
424
- let saved;
425
- try {
426
- saved = await deps.repository.appendVersion({
427
- version,
428
- actorId: actor.id,
429
- baseVersionId,
430
- idempotencyKey,
431
- auditId: deps.id(),
432
- });
433
- }
434
- catch (error) {
435
- await deps.content.delete(contentKey).catch(() => undefined);
436
- throw error;
437
- }
438
- if (saved === "conflict") {
439
- await deps.content.delete(contentKey);
440
- throw new IntelError(409, "version_conflict", "A newer version already exists");
441
- }
442
- await deps.indexing.enqueue(version.id);
443
- return {
444
- node: await requireVisible(actor, node.id),
445
- version,
446
- definition,
447
- applicationId: await deps.repository.agentApplicationId(node.id),
448
- };
449
- }
450
285
  async function requireTable(actor, nodeId) {
451
286
  const node = await requireVisible(actor, nodeId);
452
287
  if (node.kind !== "table") {
@@ -457,310 +292,13 @@ export function createNodes(deps) {
457
292
  }
458
293
  return node;
459
294
  }
460
- // ── Boards (#285) ──────────────────────────────────────────────────────────────────────────────
461
- // How often one task operation may meet a board that moved under it before it gives up. Every
462
- // round is a fresh read and the SAME operation applied again — see `applyToBoard` for why that is
463
- // the point rather than a retry loop hiding a race.
464
- const boardWriteAttempts = 5;
465
- function parseStoredBoard(body) {
466
- let parsed;
467
- try {
468
- parsed = JSON.parse(body);
469
- }
470
- catch {
471
- throw new IntelError(500, "board_unreadable", "The stored board cannot be read");
472
- }
473
- // ⚠️ Upgraded BEFORE validation, not after. `terminal` is required now (anchrd/intel#311), and
474
- // this parse is deliberately loud — so a board written before the flag existed would answer
475
- // `board_unreadable` rather than "old", and every board in every installation would break on
476
- // deploy. `upgradeStoredBoard` fills only what is missing; the next write persists it.
477
- const document = BoardDocument.safeParse(upgradeStoredBoard(parsed));
478
- // ⚠️ Loud, unlike the tolerant read the share warning makes of an agent definition. A board IS
479
- // its document: answering with an empty one would show somebody a board with no tasks on it,
480
- // and the next write would store that as the truth.
481
- if (!document.success) {
482
- throw new IntelError(500, "board_unreadable", "The stored board cannot be read");
483
- }
484
- return document.data;
485
- }
486
- /**
487
- * The board a node currently holds.
488
- *
489
- * ⚠️ A board with no version yet answers with the DEFAULTS rather than with nothing (#285). A
490
- * fresh board already has its five columns — the same way a fresh table answers with no header
491
- * instead of refusing — and the first write is what puts them into R2. Nothing has to be created
492
- * twice for a board to be usable the moment it exists.
493
- */
494
- async function boardDocumentOf(node) {
495
- if (node.currentVersionId === null)
496
- return board.defaultBoard();
497
- const version = await deps.repository.getVersion(node.currentVersionId);
498
- if (!version)
499
- throw new IntelError(500, "version_missing", "Current version is missing");
500
- const body = await deps.content.get(version.contentKey);
501
- if (body === null)
502
- throw new IntelError(500, "content_missing", "Version content is missing");
503
- return parseStoredBoard(body);
504
- }
505
- async function requireBoard(actor, nodeId) {
506
- const node = await requireVisible(actor, nodeId);
507
- if (node.kind !== "board") {
508
- throw new IntelError(409, "not_a_board", "Only boards accept task operations");
509
- }
510
- if (!(await deps.repository.can(actor, node.id, "write"))) {
511
- throw new IntelError(403, "node_forbidden", "Board cannot be edited");
512
- }
513
- return node;
514
- }
515
- /**
516
- * What a task points at outside its own board, checked before anything is written (#285).
517
- *
518
- * ⚠️ Here rather than in `board.ts`, because both questions are reads against the tree and its
519
- * ACL, and the board module is deliberately pure. A reference is refused when this actor cannot
520
- * see it — with one wording for "there is no such node" and "you may not see it", because telling
521
- * them apart would confirm the existence of a node somebody has no access to (#41).
522
- */
523
- async function checkBoardTargets(actor, task) {
524
- const references = [...new Set(task.references)];
525
- if (references.length > 0) {
526
- const visible = new Set((await deps.repository.resolveVisibleTitles(actor, references)).map((entry) => entry.nodeId));
527
- const missing = references.filter((reference) => !visible.has(reference));
528
- if (missing.length > 0) {
529
- throw new IntelError(422, "board_reference_unknown", `A reference has to name a node you can see, and ${missing.length === 1 ? "one does" : `${missing.length} do`} not`);
530
- }
531
- }
532
- if (task.assignee?.type === "agent") {
533
- const assignee = await deps.repository.getVisible(actor, task.assignee.nodeId);
534
- if (assignee?.kind !== "agent") {
535
- throw new IntelError(422, "board_assignee_unknown", "An agent assignee has to name an agent you can see");
536
- }
537
- }
538
- }
539
- /**
540
- * One new board version, or `null` when somebody wrote in between.
541
- *
542
- * ⚠️ `segment` is `null`, like a document's and unlike a table's (#135): every version here holds
543
- * the whole board, so there is nothing for a snapshot to mark off from what came before it.
544
- *
545
- * ⚠️ `baseVersionId` is the version this service just read, never one a caller sent. It is the
546
- * board's optimistic lock and it is entirely internal — see `applyToBoard`.
547
- *
548
- * ⚠️ The R2 object is written before the version row and deleted again if the row does not land,
549
- * the same order `save` keeps: an orphaned object is invisible, while a version row pointing at
550
- * nothing is a board that cannot be read at all.
551
- */
552
- async function writeBoardVersion(input) {
553
- const body = JSON.stringify(input.document);
554
- const versionId = deps.id();
555
- const contentKey = contentKeyFor(input.node.id, versionId);
556
- const version = {
557
- id: versionId,
558
- nodeId: input.node.id,
559
- sequence: await nextSequence(input.node),
560
- contentKey,
561
- mediaType: BoardMediaType,
562
- contentHash: await deps.hash(body),
563
- size: new TextEncoder().encode(body).byteLength,
564
- segment: null,
565
- createdBy: input.actor.id,
566
- createdAt: deps.now().toISOString(),
567
- };
568
- await deps.content.put(contentKey, body, BoardMediaType);
569
- let saved;
570
- try {
571
- saved = await deps.repository.appendSnapshotVersion({
572
- version,
573
- actorId: input.actor.id,
574
- baseVersionId: input.node.currentVersionId,
575
- operation: input.operation,
576
- metadata: input.metadata,
577
- idempotencyKey: input.idempotencyKey,
578
- auditId: deps.id(),
579
- });
580
- }
581
- catch (error) {
582
- await deps.content.delete(contentKey).catch(() => undefined);
583
- // ⚠️ The same key at the same moment, from the caller's own retry. `idempotency_keys` is keyed
584
- // by (actor, operation, key), so the second write's row collides and D1 rolls the whole batch
585
- // back — which is the right outcome and the wrong error. The first write owns the act; this
586
- // one reports a conflict so the caller answers with it, exactly as `appendTableVersion` does
587
- // one layer down. Anything else is a real failure and still raises.
588
- const owned = await deps.repository.findIdempotentNode(input.actor.id, input.operation, input.idempotencyKey);
589
- if (owned === null)
590
- throw error;
591
- return null;
592
- }
593
- if (saved === "conflict") {
594
- await deps.content.delete(contentKey);
595
- return null;
596
- }
597
- // A board's `references` are links in the graph exactly like a document's inline ones, so the
598
- // same reconciliation runs on the same body (#285, #41).
599
- await reconcileTextLinks(input.actor, input.node.id, BoardMediaType, body);
600
- await deps.indexing.enqueue(version.id);
601
- return version;
602
- }
603
- /**
604
- * One task operation, applied to the board and written as its next version (#285).
605
- *
606
- * ⚠️ THIS is why a board has no `baseVersionId` on its inputs. The caller names a task id; the
607
- * service reads the current board, applies the change to THAT document and writes it against the
608
- * version it read. When somebody else landed a write in between, the insert refuses, and the
609
- * answer is not `version_conflict` but another round: read again, apply the same change to the
610
- * board as it now stands, write again. Two agents moving two different cards therefore both get
611
- * through, which is the whole difference to a table's positions (`TableRowPosition`) — those
612
- * cannot survive a rewrite, a task id can.
613
- *
614
- * ⚠️ Bounded, and the bound is not decoration. A board somebody is hammering must eventually
615
- * answer rather than loop; after `boardWriteAttempts` rounds the caller hears the same
616
- * `version_conflict` any other contended write gives them.
617
- *
618
- * ⚠️ A replayed key answers out of the version it wrote AND the audit metadata beside it. The
619
- * document alone cannot say which task an operation touched or how many a delete removed — it is
620
- * the state after the write, and a deleted subtree is not in it.
621
- */
622
- async function applyToBoard(actor, input, operation, apply) {
623
- let node = await requireBoard(actor, input.nodeId);
624
- const replayed = await replayedBoardWrite(actor, node, operation, input.idempotencyKey);
625
- if (replayed)
626
- return replayed;
627
- for (let attempt = 0; attempt < boardWriteAttempts; attempt += 1) {
628
- const applied = apply(await boardDocumentOf(node));
629
- const version = await writeBoardVersion({
630
- actor,
631
- node,
632
- document: applied.document,
633
- metadata: applied.metadata,
634
- operation,
635
- idempotencyKey: input.idempotencyKey,
636
- });
637
- if (version !== null) {
638
- return {
639
- node: await requireVisible(actor, node.id),
640
- version,
641
- document: applied.document,
642
- metadata: applied.metadata,
643
- };
644
- }
645
- node = await requireVisible(actor, input.nodeId);
646
- /**
647
- * ⚠️ Asked again after EVERY refused round, not only before the first (#285).
648
- *
649
- * A conflict says "somebody wrote in between" and usually that somebody was working on
650
- * another task — retrying is then exactly right. But it can also have been this very call,
651
- * arriving twice: a double click, or a client that retried before the first answer came back.
652
- * Retrying THAT blindly would re-use an idempotency key the winning write already owns, and
653
- * the second attempt would die on the primary key of `idempotency_keys` — an internal error
654
- * where the caller was promised a replay. So the key is asked again first, and if it is spent
655
- * the answer is the write that owns it.
656
- */
657
- const raced = await replayedBoardWrite(actor, node, operation, input.idempotencyKey);
658
- if (raced)
659
- return raced;
660
- }
661
- throw new IntelError(409, "version_conflict", "This board is being written to too quickly");
662
- }
663
- // The write this key already made, or `null` while it is unspent. The version, the document it
664
- // holds and the metadata beside it — all three, or nothing: a half-readable replay would answer
665
- // with a task the caller never got.
666
- async function replayedBoardWrite(actor, node, operation, idempotencyKey) {
667
- const replayedId = await deps.repository.findIdempotentNode(actor.id, operation, idempotencyKey);
668
- if (replayedId === null)
669
- return null;
670
- const version = await deps.repository.getVersion(replayedId);
671
- if (!version)
672
- return null;
673
- const metadata = await deps.repository.findSnapshotMetadata(actor.id, operation, idempotencyKey);
674
- if (!metadata)
675
- return null;
676
- const body = await deps.content.get(version.contentKey);
677
- if (body === null)
678
- return null;
679
- return { node, version, document: parseStoredBoard(body), metadata };
680
- }
681
- // The task an operation reports, read back out of the version it wrote. The id comes from the
682
- // audit metadata, so a replay answers with exactly the task the first attempt created.
683
- function writtenTask(written) {
684
- const taskId = written.metadata.taskId;
685
- const task = typeof taskId === "string"
686
- ? written.document.tasks.find((candidate) => candidate.id === taskId)
687
- : undefined;
688
- if (!task)
689
- throw new IntelError(500, "board_unreadable", "The written task cannot be read back");
690
- return task;
691
- }
692
- /**
693
- * What the grant just written does not cover: the documents the flows in this folder read that
694
- * the new principal still cannot.
695
- *
696
- * ⚠️ It warns and never blocks. A node reference across the folder edge is a possible
697
- * failure, not a way around permissions — that distinction is the whole of ADR-0004 §4, and a
698
- * block here would force everyone using one central policy document to duplicate it.
699
- *
700
- * ⚠️ Which documents may be named is `getVisible`, the lookup every other node read goes
701
- * through, and never a second rule written for a message: the ones this actor may see are named,
702
- * the rest are counted. That is exactly where #17 and #19 went wrong in review.
703
- *
704
- * The flow side is filtered by the flow list's own predicate as well. It changes nothing today —
705
- * sharing needs `read` on the folder and `read` inherits over the whole subtree, so every flow in
706
- * it is already visible — but the verbs are independent by decision (ADR-0004 §2), and the day
707
- * `share` stops implying `read` this must not be the place that quietly starts leaking.
708
- *
709
- * It does say one thing about the grantee: that a named document is not readable for them. That
710
- * is the consequence of this actor's own decision, on their own folder, at the moment they make
711
- * it, and it is the smallest answer that lets them make it — the alternative is the flow failing
712
- * for someone else next week with nobody able to say why. It never enumerates the principal's
713
- * access in general and never leaves the documents these flows actually name.
714
- */
715
- /**
716
- * The nodes the agents in this folder's subtree name in their definitions (#139, ADR-0005 §2).
717
- *
718
- * ⚠️ Read from R2 and parsed, because an agent's references live in its definition body and not
719
- * in D1 — unlike a flow's, which sit in `flow_versions.graph_json` and can be matched in SQL.
720
- * There is no query that can answer this, and writing one against a guessed JSON shape is exactly
721
- * how the flow side came to match a graph that migration 0008 had already abolished
722
- * (anchrd/intel#153): the query kept returning nothing and the warning silently stopped arriving.
723
- * Parsing the real contract schema is what makes this one fail loudly instead of quietly.
724
- *
725
- * ⚠️ A definition that will not parse is SKIPPED rather than thrown — the opposite of the read
726
- * path above, deliberately. This runs while somebody is sharing a folder, and a warning that
727
- * raises would turn one broken agent into a folder nobody can share. The read path is where a
728
- * corrupt definition has to be loud; here the cost of loudness is paid by the wrong person.
729
- */
730
- async function agentNodeReferences(actor, folderId) {
731
- const keys = await deps.repository.listVisibleAgentDefinitionKeys(actor, folderId);
732
- const referenced = [];
733
- for (const key of keys) {
734
- const body = await deps.content.get(key);
735
- if (body === null)
736
- continue;
737
- try {
738
- const parsed = AgentDefinition.safeParse(JSON.parse(body));
739
- if (!parsed.success)
740
- continue;
741
- for (const reference of parsed.data.references)
742
- referenced.push(reference.nodeId);
743
- }
744
- catch {
745
- // Unparsable JSON is skipped for the reason above: this runs inside a share, and one
746
- // broken agent must not be able to make a folder unshareable.
747
- }
748
- }
749
- return referenced;
750
- }
751
295
  async function unreadableForPrincipal(actor, folderId, principal) {
752
296
  const grantee = asPrincipalActor(principal);
753
297
  const titles = [];
754
298
  let hidden = 0;
755
- // Flows and agents reach for material the same way and the grant covers neither, so they are
756
- // one list. Deduplicated, because a document that is both a flow's input and an agent's system
757
- // message is one thing the new principal cannot read, not two.
758
- const referenced = [
759
- ...new Set([
760
- ...(await deps.flowNodeReferences(actor, folderId)),
761
- ...(await agentNodeReferences(actor, folderId)),
762
- ]),
763
- ];
299
+ // What a flow reaches for and the grant does not cover. Deduplicated, because a document two
300
+ // flows both name is one thing the new principal cannot read, not two.
301
+ const referenced = [...new Set(await deps.flowNodeReferences(actor, folderId))];
764
302
  for (const resourceId of referenced) {
765
303
  if (await deps.repository.can(grantee, resourceId, "read"))
766
304
  continue;
@@ -884,12 +422,6 @@ export function createNodes(deps) {
884
422
  return (await deps.repository.can(actor, folder.id, "write")) ? "ok" : "forbidden";
885
423
  },
886
424
  async create(actor, input) {
887
- // The generic path can file an agent row too — one without a definition or a principal — so
888
- // the #190 gate stands here as well. Only where no runtime exists: with one, this path stays
889
- // exactly as it was, which is part of the same ticket.
890
- if (input.kind === "agent" && !deps.agentRuntimeAvailable()) {
891
- throw new IntelError(503, "agent_runtime_not_configured", "This installation has no agent runtime, so agents cannot be created");
892
- }
893
425
  const existingId = await deps.repository.findIdempotentNode(actor.id, "node.create", input.idempotencyKey);
894
426
  if (existingId)
895
427
  return await requireVisible(actor, existingId);
@@ -974,230 +506,6 @@ export function createNodes(deps) {
974
506
  await deps.indexing.enqueue(version.id);
975
507
  return { node: updated, version, content: input.content };
976
508
  },
977
- async getAgent(actor, input) {
978
- return await agentOf(await requireVisible(actor, input.nodeId));
979
- },
980
- async listAgents(actor, input) {
981
- return { items: await deps.repository.listVisibleAgents(actor, input) };
982
- },
983
- /**
984
- * The Gate Application, the node, and its first definition, in that order.
985
- *
986
- * ⚠️ Two writes behind one call, so the replay has to be answered for both. The node
987
- * carries the caller's key under `node.create`; the definition carries a derived one under
988
- * `node.save`, because the two operations share an idempotency table and the same key twice
989
- * would make the second write look like a replay of the first. A caller who repeats the
990
- * request gets the agent that already exists rather than a second one beside it.
991
- *
992
- * ⚠️ A REPLAY MAKES NO SECOND APPLICATION. It still returns the agent that exists — that promise
993
- * is not weakened — but a repeat that minted a fresh principal would leave the installation with
994
- * two machine accounts for one agent, one of which nobody would ever switch off, and would
995
- * replace a working agent's key with one it never asked for. Gate is not asked at all on that
996
- * path, and no key travels either. What a replay DOES do is arm the agent's schedules again
997
- * (#214) — that is idempotent, and it is the repair for a create whose arming failed.
998
- *
999
- * ⚠️ Gate is asked BEFORE anything is written, and that ordering IS the answer to "what if Gate
1000
- * is down". Nothing exists yet at that moment, so a Gate that does not answer leaves no node, no
1001
- * version, no R2 object and no audit event — the agent simply does not come into being, and the
1002
- * caller repeats the request. The alternative, a node that exists without a principal plus a
1003
- * way to fill it in later, buys nothing: such an agent starts no run (`agent_key_missing` in the
1004
- * runtime), so it would be a broken row waiting for a second, easily forgotten step — and
1005
- * "somebody must remember to do it in Gate" is the exact chore this ticket removes.
1006
- *
1007
- * ⚠️ If the node write fails after Gate succeeded, the fresh Application is switched off again,
1008
- * best effort. A live principal nobody references is precisely the invisible access #182 exists
1009
- * to prevent, and disabling rather than deleting keeps the compensation consistent with what
1010
- * archiving does — Gate's `DELETE` would take the identity with it (`anchrd/gate#224`).
1011
- */
1012
- async createAgent(actor, input, caller) {
1013
- // Before anything else — before the idempotency read, before Gate (#190). An installation
1014
- // without a runtime refuses the agent by name, and the refusal costs no storage read, mints
1015
- // no principal, and is the same on every surface because it lives here.
1016
- if (!deps.agentRuntimeAvailable()) {
1017
- throw new IntelError(503, "agent_runtime_not_configured", "This installation has no agent runtime, so agents cannot be created");
1018
- }
1019
- const definitionKey = `${input.idempotencyKey}:definition`;
1020
- const existingId = await deps.repository.findIdempotentNode(actor.id, "node.create", input.idempotencyKey);
1021
- if (existingId) {
1022
- const existing = await agentOf(await requireVisible(actor, existingId));
1023
- // ⚠️ A replay arms as well, and that is what makes a create whose sync failed repairable
1024
- // (#214): repeating it with the same key mints nothing, returns this agent, and puts its
1025
- // alarm right. Without it the only repair left would be a fresh key — a second agent.
1026
- if ((existing.definition?.schedules.length ?? 0) > 0) {
1027
- await deps.agentSchedules.sync({ token: caller.token, agentId: existing.node.id });
1028
- }
1029
- return existing;
1030
- }
1031
- if (input.parentId !== null && !(await deps.repository.can(actor, input.parentId, "write"))) {
1032
- throw new IntelError(403, "node_forbidden", "Parent folder cannot be edited");
1033
- }
1034
- // Before Gate, for the same reason the runtime check is before Gate: a delegation the saver
1035
- // cannot back — or a reference no role can use (#255) — must not leave a machine principal
1036
- // behind for an agent that was never created.
1037
- await checkReferences(actor, input.definition);
1038
- const definition = await delegationOf(actor, input.definition);
1039
- const nodeId = deps.id();
1040
- // One title for both, and it is the unescaped one: Gate's name is minted before the node row
1041
- // and can never be brought back into line with it afterwards (#202).
1042
- const title = plainTitle(input.title);
1043
- // The Application's name is what a person reads in Gate's list, so it has to be enough to
1044
- // recognise the agent by. Title alone would leave two agents called "Research" indis-
1045
- // tinguishable; the node ID is what the runtime's key map is keyed by anyway.
1046
- const application = await deps.applications.create({
1047
- token: caller.token,
1048
- name: `Intel agent ${title} (${nodeId})`,
1049
- });
1050
- // ⚠️ The handover happens BEFORE the node is written, for the same reason Gate is asked before
1051
- // it: at this moment nothing exists on Intel's side, so a runtime that does not take the key
1052
- // leaves no node, no version, no R2 object and no audit event — the agent simply does not come
1053
- // into being and the caller repeats the request. The other order would produce exactly the
1054
- // agent #200 is about: a node that looks finished and dies at its first run. What it can leave
1055
- // behind is a Durable Object holding a key for a node id that will never exist, which is
1056
- // ciphertext nobody can address — ULIDs are not reused.
1057
- try {
1058
- await deps.agentKeys.store({ token: caller.token, agentId: nodeId, key: application.key });
1059
- }
1060
- catch (error) {
1061
- await deps.applications
1062
- .setEnabled({ token: caller.token, applicationId: application.id, enabled: false })
1063
- .catch(() => undefined);
1064
- throw error;
1065
- }
1066
- const timestamp = deps.now().toISOString();
1067
- let node;
1068
- try {
1069
- node = await deps.repository.insertNode({
1070
- node: {
1071
- id: nodeId,
1072
- parentId: input.parentId,
1073
- kind: "agent",
1074
- title,
1075
- description: input.description,
1076
- ownerId: actor.id,
1077
- currentVersionId: null,
1078
- createdAt: timestamp,
1079
- updatedAt: timestamp,
1080
- archivedAt: null,
1081
- },
1082
- actorId: actor.id,
1083
- idempotencyKey: input.idempotencyKey,
1084
- auditId: deps.id(),
1085
- application: { id: application.id },
1086
- });
1087
- }
1088
- catch (error) {
1089
- await deps.applications
1090
- .setEnabled({ token: caller.token, applicationId: application.id, enabled: false })
1091
- .catch(() => undefined);
1092
- throw error;
1093
- }
1094
- // ⚠️ No key in the answer, and no field one could travel in (D29). `application.key` has been
1095
- // in one local variable since Gate returned it, went into the runtime, and is out of reach of
1096
- // every surface above this line.
1097
- const created = (await writeAgentVersion(actor, node, definition, null, definitionKey));
1098
- /**
1099
- * ⚠️ Only when this definition actually schedules something — unlike the save below, which
1100
- * arms unconditionally. A brand-new agent has no earlier definition, so there is no alarm that
1101
- * could be left standing: no schedules means there is nothing to arm and nothing to clear, and
1102
- * the screen creates every agent that way (the profile is where schedules are added).
1103
- *
1104
- * ⚠️ It fails loudly, and the agent stays. Everything is written by now and nothing here can
1105
- * roll an agent back, so the caller is told the one thing they can act on: the schedules are
1106
- * not armed. The repair is repeating the create with the SAME idempotency key — which returns
1107
- * this agent, mints no second Application, and arms it. A fresh key would create a second
1108
- * agent beside this one, which is why the message says "again" and not "retry".
1109
- */
1110
- if (definition.schedules.length > 0) {
1111
- await deps.agentSchedules.sync({ token: caller.token, agentId: node.id });
1112
- }
1113
- return created;
1114
- },
1115
- /**
1116
- * A new key for the Application this agent runs as, handed straight to the runtime (D29, #207).
1117
- *
1118
- * ⚠️ A write on the agent node, not a run: `knowledge/write` at the door and the resource ACL
1119
- * here. Whoever may change what an agent does may change which key it does it with; being
1120
- * allowed to press "Run now" says nothing about being allowed to decide its principal.
1121
- *
1122
- * ⚠️ Gate first, runtime second, and nothing in between. Gate issues the replacement before it
1123
- * revokes the old one, so a rotation that fails at Gate changes nothing at all; one that fails
1124
- * at the handover leaves the agent with a key it no longer has — named, repairable, and fixed by
1125
- * rotating again. The alternative order cannot exist: the runtime has nothing to store until
1126
- * Gate has answered.
1127
- *
1128
- * ⚠️ The key is in one local variable and in no answer. This is the operation that exists BECAUSE
1129
- * Gate hands a key out exactly once — reading one back is not something any repair can do.
1130
- */
1131
- async rotateAgentKey(actor, input, caller) {
1132
- const node = await requireVisible(actor, input.nodeId);
1133
- if (node.kind !== "agent") {
1134
- throw new IntelError(409, "not_an_agent", "Only agents have an application key");
1135
- }
1136
- if (!(await deps.repository.can(actor, node.id, "write"))) {
1137
- throw new IntelError(403, "node_forbidden", "This agent cannot be edited");
1138
- }
1139
- if (!deps.agentRuntimeAvailable()) {
1140
- throw new IntelError(503, "agent_runtime_not_configured", "This installation has no agent runtime, so there is nothing to give a key to");
1141
- }
1142
- const applicationId = await deps.repository.agentApplicationId(node.id);
1143
- if (applicationId === null) {
1144
- // An agent written before #182, restored from a bundle, or imported. Giving it a principal
1145
- // is an act in Gate, and inventing one here would make Intel the author of a machine
1146
- // account nobody asked for.
1147
- throw new IntelError(409, "agent_application_missing", "This agent has no Gate application, so there is no key to replace");
1148
- }
1149
- const rotated = await deps.applications.rotateKey({ token: caller.token, applicationId });
1150
- await deps.agentKeys.store({ token: caller.token, agentId: node.id, key: rotated.key });
1151
- const rotatedAt = deps.now().toISOString();
1152
- // ⚠️ Written LAST, and only once both sides took the change. An event ahead of the runtime
1153
- // would record a rotation that may not have arrived, and this row is the only trace the act
1154
- // leaves in Intel — nothing about the node itself changes, so a reader who diffed versions
1155
- // would see an agent that silently began acting with a different credential.
1156
- await deps.repository.recordAgentKeyRotation({
1157
- auditId: deps.id(),
1158
- actorId: actor.id,
1159
- nodeId: node.id,
1160
- applicationId,
1161
- occurredAt: rotatedAt,
1162
- });
1163
- return { nodeId: node.id, applicationId, rotatedAt };
1164
- },
1165
- /**
1166
- * A new definition version, and the alarm that has to match it (#214).
1167
- *
1168
- * ⚠️ The runtime is told to re-arm after EVERY definition write, not only after one that names a
1169
- * schedule. The alternative — comparing against the definition that was there before — needs the
1170
- * previous document read back out of R2, and it is wrong in exactly the direction that matters:
1171
- * a save that removes the LAST schedule has nothing to compare against in the new definition and
1172
- * is precisely the write that has to clear the alarm. One binding call per save buys the rule
1173
- * "what is written is what is armed" without a comparison anybody could get backwards.
1174
- *
1175
- * ⚠️ Written first, armed second, and the definition STAYS when the arming fails. It has to: the
1176
- * runtime reads the definition back out of Intel to arm it, so there is no order in which it
1177
- * could be armed before it exists. What the caller gets instead of silence is
1178
- * `agent_schedules_not_armed` — the version is in the history, the profile shows it, and saving
1179
- * again arms it. Rolling the version back would be worse than the unarmed alarm: it would throw
1180
- * away an edit somebody made because a Worker was briefly unreachable.
1181
- */
1182
- async saveAgentDefinition(actor, input, caller) {
1183
- const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
1184
- if (existingId)
1185
- return await agentOf(await requireVisible(actor, input.nodeId));
1186
- const node = await requireVisible(actor, input.nodeId);
1187
- if (node.kind !== "agent") {
1188
- throw new IntelError(409, "not_an_agent", "Only agents accept a definition");
1189
- }
1190
- if (!(await deps.repository.can(actor, node.id, "write"))) {
1191
- throw new IntelError(403, "node_forbidden", "This agent cannot be edited");
1192
- }
1193
- if (node.currentVersionId !== input.baseVersionId) {
1194
- throw new IntelError(409, "version_conflict", "A newer version already exists");
1195
- }
1196
- await checkReferences(actor, input.definition);
1197
- const saved = await writeAgentVersion(actor, node, await delegationOf(actor, input.definition), input.baseVersionId, input.idempotencyKey);
1198
- await deps.agentSchedules.sync({ token: caller.token, agentId: node.id });
1199
- return saved;
1200
- },
1201
509
  async saveAttachment(actor, input) {
1202
510
  const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
1203
511
  if (existingId) {
@@ -1436,80 +744,6 @@ export function createNodes(deps) {
1436
744
  });
1437
745
  return await tableOf(await requireVisible(actor, node.id));
1438
746
  },
1439
- /**
1440
- * The board, as one document (#285).
1441
- *
1442
- * ⚠️ `read` and not `write`: seeing a board is the ordinary node read, and `requireBoard` — with
1443
- * its `write` check — is for the five operations that change it. A board nobody may edit is
1444
- * still a board they may look at.
1445
- */
1446
- async getBoard(actor, input) {
1447
- const node = await requireVisible(actor, input.nodeId);
1448
- if (node.kind !== "board") {
1449
- throw new IntelError(404, "board_not_found", "Board was not found");
1450
- }
1451
- return { node, board: await boardDocumentOf(node), versionId: node.currentVersionId };
1452
- },
1453
- async configureBoard(actor, input) {
1454
- const written = await applyToBoard(actor, input, "node.board_configure", (document) => ({
1455
- document: board.configure(document, input.statuses),
1456
- // The ids and not the labels: audit is metadata, and a label is text somebody wrote.
1457
- metadata: { statuses: input.statuses.map((status) => status.id) },
1458
- }));
1459
- return { node: written.node, version: written.version, statuses: written.document.statuses };
1460
- },
1461
- async addBoardTask(actor, input) {
1462
- // Before the document is touched at all: a task that names a node nobody can see is refused
1463
- // rather than stored, and the refusal costs no write (#285).
1464
- await checkBoardTargets(actor, { references: input.references, assignee: input.assignee });
1465
- const written = await applyToBoard(actor, input, "node.board_task_add", (document) => {
1466
- const added = board.addTask(document, input);
1467
- return { document: added.board, metadata: { taskId: added.task.id } };
1468
- });
1469
- return { node: written.node, version: written.version, task: writtenTask(written) };
1470
- },
1471
- async updateBoardTask(actor, input) {
1472
- await checkBoardTargets(actor, {
1473
- references: input.references ?? [],
1474
- assignee: input.assignee ?? null,
1475
- });
1476
- const written = await applyToBoard(actor, input, "node.board_task_update", (document) => {
1477
- const updated = board.updateTask(document, input);
1478
- return { document: updated.board, metadata: { taskId: updated.task.id } };
1479
- });
1480
- return { node: written.node, version: written.version, task: writtenTask(written) };
1481
- },
1482
- async moveBoardTask(actor, input) {
1483
- const written = await applyToBoard(actor, input, "node.board_task_move", (document) => {
1484
- const moved = board.moveTask(document, input);
1485
- return {
1486
- document: moved.board,
1487
- metadata: { taskId: moved.task.id, status: moved.task.status },
1488
- };
1489
- });
1490
- return { node: written.node, version: written.version, task: writtenTask(written) };
1491
- },
1492
- /**
1493
- * A task and everything under it (#285).
1494
- *
1495
- * ⚠️ The count is read back out of the write's own metadata rather than recomputed. A replay
1496
- * has to answer with what the first attempt actually removed, and by then the descendants are
1497
- * no longer in the document to be counted.
1498
- */
1499
- async deleteBoardTask(actor, input) {
1500
- const written = await applyToBoard(actor, input, "node.board_task_delete", (document) => {
1501
- const removed = board.deleteTask(document, input.taskId);
1502
- return {
1503
- document: removed.board,
1504
- metadata: { taskId: input.taskId, deleted: removed.deleted },
1505
- };
1506
- });
1507
- const deleted = written.metadata.deleted;
1508
- if (typeof deleted !== "number") {
1509
- throw new IntelError(500, "board_unreadable", "The delete did not record what it removed");
1510
- }
1511
- return { node: written.node, version: written.version, deleted };
1512
- },
1513
747
  async listVersions(actor, nodeId) {
1514
748
  await requireVisible(actor, nodeId);
1515
749
  return { items: await deps.repository.listVersions(nodeId) };
@@ -1559,28 +793,18 @@ export function createNodes(deps) {
1559
793
  return updated;
1560
794
  },
1561
795
  /**
1562
- * Archiving and restoring — and for an agent, its Gate Application with it (#182).
1563
- *
1564
- * ⚠️ Gate is switched BEFORE the node row, and the order is the whole safety argument. The state
1565
- * that must not exist is "archived agent, live principal": a machine account that still holds
1566
- * every grant the agent had, on a node nobody looks at any more. Writing the row first and then
1567
- * failing at Gate produces exactly that. The reverse leaves "live agent, switched-off
1568
- * principal" — the agent refuses its next run by name (`agent_principal_rejected` in the
1569
- * runtime), which somebody notices the same day and which repeating the call repairs.
796
+ * Archiving and restoring.
1570
797
  *
1571
- * ⚠️ The optimistic check is made twice on purpose: cheaply here so a stale `baseUpdatedAt`
1572
- * never reaches Gate, and authoritatively in the statement that writes. Only a genuine race
1573
- * gets past the first, and that path puts Gate back the way it was before refusing.
798
+ * ⚠️ Nothing in this repository removes a `nodes` row `archived_at` is what "delete" means
799
+ * here, which is why a restore is the same call with `archived: false` and why both directions
800
+ * go through the indexing queue below.
1574
801
  *
1575
- * ⚠️ Gate is asked even when the node is already in the requested state. Both routes are
1576
- * idempotent there, and it is what makes a repeated call heal a run that died between the two
1577
- * writes the alternative would be an agent stuck live because its node already said archived.
1578
- *
1579
- * ⚠️ Nothing here is skipped for an agent WITHOUT an Application. There is no principal to
1580
- * switch, Gate is not called, and the node archives like a document — the case of an agent
1581
- * imported, restored from a bundle, or written before #182.
802
+ * ⚠️ Until #390 this also switched an agent's Gate Application off with the node, and the ORDER
803
+ * of the two writes was the whole safety argument. With the agents parked there is no second
804
+ * system to keep in step, and `caller` survives only because `archive` is still the one node
805
+ * mutation a surface hands its bearer to.
1582
806
  */
1583
- async archive(actor, input, caller) {
807
+ async archive(actor, input, _caller) {
1584
808
  const current = await requireVisible(actor, input.nodeId);
1585
809
  if (!(await deps.repository.can(actor, current.id, "write"))) {
1586
810
  throw new IntelError(403, "node_forbidden", "This node cannot be edited");
@@ -1590,17 +814,6 @@ export function createNodes(deps) {
1590
814
  // about a request that is not happening.
1591
815
  if (replayedId)
1592
816
  return await requireVisible(actor, replayedId);
1593
- const applicationId = current.kind === "agent" ? await deps.repository.agentApplicationId(current.id) : null;
1594
- if (applicationId !== null) {
1595
- if (current.updatedAt !== input.baseUpdatedAt) {
1596
- throw new IntelError(409, "update_conflict", "This node was changed by another editor");
1597
- }
1598
- await deps.applications.setEnabled({
1599
- token: caller.token,
1600
- applicationId,
1601
- enabled: !input.archived,
1602
- });
1603
- }
1604
817
  const updatedAt = deps.now().toISOString();
1605
818
  const updated = await deps.repository.archiveNode({
1606
819
  nodeId: current.id,
@@ -1612,19 +825,28 @@ export function createNodes(deps) {
1612
825
  auditId: deps.id(),
1613
826
  });
1614
827
  if (updated === "conflict") {
1615
- if (applicationId !== null) {
1616
- // Back to what the node actually said, not to the inverse of what was asked: repeating an
1617
- // archive on an already archived agent would otherwise switch its principal back ON.
1618
- await deps.applications
1619
- .setEnabled({
1620
- token: caller.token,
1621
- applicationId,
1622
- enabled: current.archivedAt === null,
1623
- })
1624
- .catch(() => undefined);
1625
- }
1626
828
  throw new IntelError(409, "update_conflict", "This node was changed by another editor");
1627
829
  }
830
+ /**
831
+ * ⚠️ Both directions, through the same queue every save goes through (anchrd/intel#348). The
832
+ * pass reads the node's state and does the matching thing: an archived node has its vectors
833
+ * taken out of the index, a restored one is embedded again from a record that was emptied when
834
+ * it went. Two calls to one door rather than a purge written out here, because Vectorize is a
835
+ * second system and a call into it can fail — the queue is the only thing in this repository
836
+ * that comes back for it, and a deletion nobody retries is a deletion that quietly did not
837
+ * happen.
838
+ *
839
+ * ⚠️ The full-text half is deliberately NOT emptied on the way in, and the asymmetry is the
840
+ * point rather than an oversight: an FTS row costs storage and is already invisible (every
841
+ * read joins `nodes` and drops what is archived), while a vector costs a place in a candidate
842
+ * list Vectorize caps at 100 for the whole installation. An archived node pays with
843
+ * somebody else's search results.
844
+ *
845
+ * A replayed archive never reaches this line — it returned above, on the idempotency key — so
846
+ * repeating the same call does not ask the index to forget the same names twice.
847
+ */
848
+ if (updated.currentVersionId)
849
+ await deps.indexing.enqueue(updated.currentVersionId);
1628
850
  return updated;
1629
851
  },
1630
852
  async listGrants(actor, resourceId) {
@@ -1772,18 +994,50 @@ export function createNodes(deps) {
1772
994
  if (!deps.semantic)
1773
995
  return { items: lexical.slice(0, input.limit) };
1774
996
  try {
1775
- // ⚠️ The scope narrows the vector hits afterwards, in the same D1 statement that already
1776
- // re-checks the ACL — so the candidate set has to be wide enough for that cut to leave
1777
- // something. A folder holding a dozen documents inside a tree of thousands is not reached by
1778
- // the fan-out an unscoped search gets away with, and a starved scope looks like an empty
1779
- // folder. 100 is what the port clamps to, so a scoped search asks for all there is.
1780
- const candidates = input.scopeId === undefined ? Math.min(100, input.limit * 4) : 100;
997
+ /**
998
+ * Every candidate Vectorize will give for one query, scoped or not (anchrd/intel#348).
999
+ *
1000
+ * ⚠️ A candidate is a CARD since anchrd/intel#301, not a node, so a busy board can take a
1001
+ * large share of these places and push other nodes out before this code ever sees them
1002
+ * the fold below cannot repair that, it runs on what came back. `limit * 4` was written when
1003
+ * a board was one vector and forty candidates were forty nodes; against one vector per card
1004
+ * it is a list a single board fills on its own. Asking for the ceiling is the one widening
1005
+ * available. Vectorize charges the query and not the depth, so that side is free; the price
1006
+ * is on the D1 side, where `hydrateVisibleCitations` batches 40 pairs per statement and the
1007
+ * ordinary unscoped search therefore goes from one statement to as many as three. Nothing
1008
+ * about the ceiling itself moves: a `limit` of 25 and every scoped search reached 100 before.
1009
+ *
1010
+ * ⚠️ It is a widening and not a fix, and the reason it is not is written down in
1011
+ * `packages/api/CLAUDE.md`: bounding the fan-out per NODE means filtering on a `nodeId`
1012
+ * metadata index, and Cloudflare only puts a vector into such an index when it is upserted
1013
+ * AFTER the index was created — so it would cost every installation a full re-embed of its
1014
+ * tree, plus an operational step no deployment has taken. That is anchrd/intel#356.
1015
+ *
1016
+ * ⚠️ 100 is the port's own clamp too, and it is Vectorize's documented ceiling for a query
1017
+ * that returns neither values nor metadata (50 for one that does). The scoped case has asked
1018
+ * for it since #126, because a scope cuts the candidates AFTERWARDS, in the D1 statement
1019
+ * that re-checks the ACL — a folder of a dozen documents inside a tree of thousands is not
1020
+ * reached by a narrow fan-out, and a starved scope looks like an empty folder.
1021
+ */
1022
+ const candidates = 100;
1781
1023
  const hits = await deps.semantic.search(input.query, candidates);
1782
- const semanticScores = new Map();
1024
+ /**
1025
+ * The best-scoring chunk of each node, and its score (anchrd/intel#301).
1026
+ *
1027
+ * ⚠️ The best, not the sum and not the first. A board answers once per card whose vector
1028
+ * matched, and a citation names a node — so a board of three hundred mediocre cards must not
1029
+ * out-rank one document that actually answers, and the passage the reader is shown has to be
1030
+ * the card that scored, not the one that happened to come back first.
1031
+ */
1032
+ const best = new Map();
1783
1033
  for (const hit of hits) {
1784
- semanticScores.set(hit.nodeId, Math.max(semanticScores.get(hit.nodeId) ?? 0, hit.score));
1034
+ const current = best.get(hit.nodeId);
1035
+ if (current === undefined || hit.score > current.score) {
1036
+ best.set(hit.nodeId, { chunkKey: hit.chunkKey, score: hit.score });
1037
+ }
1785
1038
  }
1786
- const semantic = await deps.repository.hydrateVisibleCitations(actor, [...semanticScores.keys()], input.scopeId);
1039
+ const semanticScores = new Map([...best].map(([nodeId, winner]) => [nodeId, winner.score]));
1040
+ const semantic = await deps.repository.hydrateVisibleCitations(actor, [...best].map(([nodeId, winner]) => ({ nodeId, chunkKey: winner.chunkKey })), input.scopeId);
1787
1041
  return {
1788
1042
  items: mergeSearchResults(lexical, semantic, semanticScores, input.limit),
1789
1043
  };
@@ -1796,6 +1050,18 @@ export function createNodes(deps) {
1796
1050
  if (actor.isAdmin !== true) {
1797
1051
  throw new IntelError(403, "reindex_forbidden", "Reindex permission is required");
1798
1052
  }
1053
+ /**
1054
+ * ⚠️ First, and before a single version is enqueued: the record of what the vector index
1055
+ * holds (anchrd/intel#301). Since #301 an indexing pass skips a chunk whose fingerprint has
1056
+ * not moved, which is what keeps a board of three hundred cards from costing three hundred
1057
+ * embeddings per save — and it would equally make `reindex` skip everything, so a vector
1058
+ * index that had been emptied would stay empty while every version was dutifully requeued.
1059
+ * That is the one failure this call exists to prevent, and it is a silent one: the answer
1060
+ * would be a search that finds less and a `queued` count that says all is well.
1061
+ *
1062
+ * The full-text half needs no equivalent because its rows are overwritten, never skipped.
1063
+ */
1064
+ await deps.repository.invalidateVectors();
1799
1065
  let queued = 0;
1800
1066
  let after = null;
1801
1067
  for (;;) {