@anchrd/intel-api 0.12.5 → 0.14.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.
@@ -2,7 +2,7 @@ import { AgentDefinition, AgentMediaType, agentReferenceAccepts, BoardDocument,
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 } from "./board/board.js";
5
+ import { createBoard, upgradeStoredBoard } from "./board/board.js";
6
6
  import { documentLinkTargets } from "./document-links/document-links.js";
7
7
  // ⚠️ The R2 key of a version written before #125 begins `knowledge/`, and it stays that way. A key
8
8
  // is stored in `node_versions.content_key` and read back from there; nothing derives one from ids,
@@ -470,7 +470,11 @@ export function createNodes(deps) {
470
470
  catch {
471
471
  throw new IntelError(500, "board_unreadable", "The stored board cannot be read");
472
472
  }
473
- const document = BoardDocument.safeParse(parsed);
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));
474
478
  // ⚠️ Loud, unlike the tolerant read the share warning makes of an agent definition. A board IS
475
479
  // its document: answering with an empty one would show somebody a board with no tasks on it,
476
480
  // and the next write would store that as the truth.
@@ -685,6 +689,34 @@ export function createNodes(deps) {
685
689
  throw new IntelError(500, "board_unreadable", "The written task cannot be read back");
686
690
  return task;
687
691
  }
692
+ /**
693
+ * What the repair renumbered, read back out of the version it wrote (anchrd/intel#341).
694
+ *
695
+ * ⚠️ The pairs come from the audit metadata for the same reason the added task's id does: a
696
+ * replayed key has to answer with what the FIRST attempt actually did, and the document alone
697
+ * cannot say. After the write every id on that board is distinct and none of them says it is new.
698
+ *
699
+ * ⚠️ The metadata carries ids and no titles — audit is metadata, and a title is text somebody
700
+ * wrote (the same line `configureBoard` draws for its status labels). The tasks themselves are
701
+ * read out of the document, so the answer is the board's own truth rather than a copy made at
702
+ * write time.
703
+ */
704
+ function renumberedTasks(written) {
705
+ const recorded = written.metadata.renumbered;
706
+ if (!Array.isArray(recorded) || recorded.length === 0) {
707
+ throw new IntelError(500, "board_unreadable", "The repair did not record what it renumbered");
708
+ }
709
+ return recorded.map((entry) => {
710
+ const pair = entry;
711
+ const task = typeof pair.taskId === "string"
712
+ ? written.document.tasks.find((candidate) => candidate.id === pair.taskId)
713
+ : undefined;
714
+ if (typeof pair.previousId !== "string" || !task) {
715
+ throw new IntelError(500, "board_unreadable", "The repaired task cannot be read back");
716
+ }
717
+ return { previousId: pair.previousId, task };
718
+ });
719
+ }
688
720
  /**
689
721
  * What the grant just written does not cover: the documents the flows in this folder read that
690
722
  * the new principal still cannot.
@@ -880,11 +912,19 @@ export function createNodes(deps) {
880
912
  return (await deps.repository.can(actor, folder.id, "write")) ? "ok" : "forbidden";
881
913
  },
882
914
  async create(actor, input) {
883
- // The generic path can file an agent row too one without a definition or a principal so
884
- // the #190 gate stands here as well. Only where no runtime exists: with one, this path stays
885
- // exactly as it was, which is part of the same ticket.
886
- if (input.kind === "agent" && !deps.agentRuntimeAvailable()) {
887
- throw new IntelError(503, "agent_runtime_not_configured", "This installation has no agent runtime, so agents cannot be created");
915
+ // ⚠️ An agent is never filed through the generic path, whatever the installation looks like
916
+ // (#193). The row it produced was an agent by kind and nothing else: no definition, and no
917
+ // Gate Application so its first run failed with `agent_key_missing` in the runtime, and the
918
+ // repair was a second, easily forgotten step in Gate. `createAgent` makes both in one go and
919
+ // asks Gate BEFORE it writes anything, so a refusal leaves nothing behind.
920
+ //
921
+ // The refusal is here rather than on each surface for the reason every rule in this file is:
922
+ // `POST /nodes` and the `node_create` MCP tool are two doors into one service, and a check at
923
+ // a door is a check somebody adds a third door past. #190 put the runtime gate here and left
924
+ // this case open on purpose — the ticket demanded "exactly as today" where a runtime exists.
925
+ // This is that case, closed.
926
+ if (input.kind === "agent") {
927
+ throw new IntelError(400, "agent_needs_agent_endpoint", "An agent is created with its definition and its principal together — use the agent endpoint (POST /nodes/agents, or the agent_create tool) rather than the generic node create");
888
928
  }
889
929
  const existingId = await deps.repository.findIdempotentNode(actor.id, "node.create", input.idempotencyKey);
890
930
  if (existingId)
@@ -1506,6 +1546,38 @@ export function createNodes(deps) {
1506
1546
  }
1507
1547
  return { node: written.node, version: written.version, deleted };
1508
1548
  },
1549
+ /**
1550
+ * The way out of a board that names one task id twice (anchrd/intel#341).
1551
+ *
1552
+ * ⚠️ It goes through `applyToBoard` like the other five, so it is one version against the board
1553
+ * as it stands, it replays on its key, and it loses a race the same way. What it does NOT do is
1554
+ * name a task — see `repairTaskIds` in `board.ts` for why the pair is exactly what cannot be
1555
+ * named, and for which entry keeps the id.
1556
+ *
1557
+ * ⚠️ No `checkBoardTargets`: nothing about `references` or `assignee` moves, so there is no
1558
+ * fresh claim on the tree to check. Re-checking the ones already stored would refuse the repair
1559
+ * over a reference somebody lost access to since the import — a board stuck for a second reason
1560
+ * on the way out of the first.
1561
+ */
1562
+ async repairBoardTaskIds(actor, input) {
1563
+ const written = await applyToBoard(actor, input, "node.board_task_repair", (document) => {
1564
+ const repaired = board.repairTaskIds(document);
1565
+ return {
1566
+ document: repaired.board,
1567
+ metadata: {
1568
+ renumbered: repaired.renumbered.map((entry) => ({
1569
+ previousId: entry.previousId,
1570
+ taskId: entry.task.id,
1571
+ })),
1572
+ },
1573
+ };
1574
+ });
1575
+ return {
1576
+ node: written.node,
1577
+ version: written.version,
1578
+ renumbered: renumberedTasks(written),
1579
+ };
1580
+ },
1509
1581
  async listVersions(actor, nodeId) {
1510
1582
  await requireVisible(actor, nodeId);
1511
1583
  return { items: await deps.repository.listVersions(nodeId) };
@@ -1621,6 +1693,26 @@ export function createNodes(deps) {
1621
1693
  }
1622
1694
  throw new IntelError(409, "update_conflict", "This node was changed by another editor");
1623
1695
  }
1696
+ /**
1697
+ * ⚠️ Both directions, through the same queue every save goes through (anchrd/intel#348). The
1698
+ * pass reads the node's state and does the matching thing: an archived node has its vectors
1699
+ * taken out of the index, a restored one is embedded again from a record that was emptied when
1700
+ * it went. Two calls to one door rather than a purge written out here, because Vectorize is a
1701
+ * second system and a call into it can fail — the queue is the only thing in this repository
1702
+ * that comes back for it, and a deletion nobody retries is a deletion that quietly did not
1703
+ * happen.
1704
+ *
1705
+ * ⚠️ The full-text half is deliberately NOT emptied on the way in, and the asymmetry is the
1706
+ * point rather than an oversight: an FTS row costs storage and is already invisible (every
1707
+ * read joins `nodes` and drops what is archived), while a vector costs a place in a candidate
1708
+ * list Vectorize caps at 100 for the whole installation. The archived board pays with
1709
+ * somebody else's search results.
1710
+ *
1711
+ * A replayed archive never reaches this line — it returned above, on the idempotency key — so
1712
+ * repeating the same call does not ask the index to forget the same names twice.
1713
+ */
1714
+ if (updated.currentVersionId)
1715
+ await deps.indexing.enqueue(updated.currentVersionId);
1624
1716
  return updated;
1625
1717
  },
1626
1718
  async listGrants(actor, resourceId) {
@@ -1768,18 +1860,50 @@ export function createNodes(deps) {
1768
1860
  if (!deps.semantic)
1769
1861
  return { items: lexical.slice(0, input.limit) };
1770
1862
  try {
1771
- // ⚠️ The scope narrows the vector hits afterwards, in the same D1 statement that already
1772
- // re-checks the ACL — so the candidate set has to be wide enough for that cut to leave
1773
- // something. A folder holding a dozen documents inside a tree of thousands is not reached by
1774
- // the fan-out an unscoped search gets away with, and a starved scope looks like an empty
1775
- // folder. 100 is what the port clamps to, so a scoped search asks for all there is.
1776
- const candidates = input.scopeId === undefined ? Math.min(100, input.limit * 4) : 100;
1863
+ /**
1864
+ * Every candidate Vectorize will give for one query, scoped or not (anchrd/intel#348).
1865
+ *
1866
+ * ⚠️ A candidate is a CARD since anchrd/intel#301, not a node, so a busy board can take a
1867
+ * large share of these places and push other nodes out before this code ever sees them
1868
+ * the fold below cannot repair that, it runs on what came back. `limit * 4` was written when
1869
+ * a board was one vector and forty candidates were forty nodes; against one vector per card
1870
+ * it is a list a single board fills on its own. Asking for the ceiling is the one widening
1871
+ * available. Vectorize charges the query and not the depth, so that side is free; the price
1872
+ * is on the D1 side, where `hydrateVisibleCitations` batches 40 pairs per statement and the
1873
+ * ordinary unscoped search therefore goes from one statement to as many as three. Nothing
1874
+ * about the ceiling itself moves: a `limit` of 25 and every scoped search reached 100 before.
1875
+ *
1876
+ * ⚠️ It is a widening and not a fix, and the reason it is not is written down in
1877
+ * `packages/api/CLAUDE.md`: bounding the fan-out per NODE means filtering on a `nodeId`
1878
+ * metadata index, and Cloudflare only puts a vector into such an index when it is upserted
1879
+ * AFTER the index was created — so it would cost every installation a full re-embed of its
1880
+ * tree, plus an operational step no deployment has taken. That is anchrd/intel#356.
1881
+ *
1882
+ * ⚠️ 100 is the port's own clamp too, and it is Vectorize's documented ceiling for a query
1883
+ * that returns neither values nor metadata (50 for one that does). The scoped case has asked
1884
+ * for it since #126, because a scope cuts the candidates AFTERWARDS, in the D1 statement
1885
+ * that re-checks the ACL — a folder of a dozen documents inside a tree of thousands is not
1886
+ * reached by a narrow fan-out, and a starved scope looks like an empty folder.
1887
+ */
1888
+ const candidates = 100;
1777
1889
  const hits = await deps.semantic.search(input.query, candidates);
1778
- const semanticScores = new Map();
1890
+ /**
1891
+ * The best-scoring chunk of each node, and its score (anchrd/intel#301).
1892
+ *
1893
+ * ⚠️ The best, not the sum and not the first. A board answers once per card whose vector
1894
+ * matched, and a citation names a node — so a board of three hundred mediocre cards must not
1895
+ * out-rank one document that actually answers, and the passage the reader is shown has to be
1896
+ * the card that scored, not the one that happened to come back first.
1897
+ */
1898
+ const best = new Map();
1779
1899
  for (const hit of hits) {
1780
- semanticScores.set(hit.nodeId, Math.max(semanticScores.get(hit.nodeId) ?? 0, hit.score));
1900
+ const current = best.get(hit.nodeId);
1901
+ if (current === undefined || hit.score > current.score) {
1902
+ best.set(hit.nodeId, { chunkKey: hit.chunkKey, score: hit.score });
1903
+ }
1781
1904
  }
1782
- const semantic = await deps.repository.hydrateVisibleCitations(actor, [...semanticScores.keys()], input.scopeId);
1905
+ const semanticScores = new Map([...best].map(([nodeId, winner]) => [nodeId, winner.score]));
1906
+ const semantic = await deps.repository.hydrateVisibleCitations(actor, [...best].map(([nodeId, winner]) => ({ nodeId, chunkKey: winner.chunkKey })), input.scopeId);
1783
1907
  return {
1784
1908
  items: mergeSearchResults(lexical, semantic, semanticScores, input.limit),
1785
1909
  };
@@ -1792,6 +1916,18 @@ export function createNodes(deps) {
1792
1916
  if (actor.isAdmin !== true) {
1793
1917
  throw new IntelError(403, "reindex_forbidden", "Reindex permission is required");
1794
1918
  }
1919
+ /**
1920
+ * ⚠️ First, and before a single version is enqueued: the record of what the vector index
1921
+ * holds (anchrd/intel#301). Since #301 an indexing pass skips a chunk whose fingerprint has
1922
+ * not moved, which is what keeps a board of three hundred cards from costing three hundred
1923
+ * embeddings per save — and it would equally make `reindex` skip everything, so a vector
1924
+ * index that had been emptied would stay empty while every version was dutifully requeued.
1925
+ * That is the one failure this call exists to prevent, and it is a silent one: the answer
1926
+ * would be a search that finds less and a `queued` count that says all is well.
1927
+ *
1928
+ * The full-text half needs no equivalent because its rows are overwritten, never skipped.
1929
+ */
1930
+ await deps.repository.invalidateVectors();
1795
1931
  let queued = 0;
1796
1932
  let after = null;
1797
1933
  for (;;) {
@@ -1,4 +1,4 @@
1
- import type { AddBoardTaskInput, AgentKeyRotated, AgentList, AppendTableRowsInput, AppendTableRowsResult, ArchiveNodeInput, BoardTaskResult, ConfigureBoardInput, ConfigureBoardResult, CreateAgentInput, CreatedAgent, CreateNodeInput, DefineTableInput, DeleteBoardTaskInput, DeleteBoardTaskResult, DeleteTableRowsInput, DeleteTableRowsResult, Flow, FlowVersion, GetAgentInput, GetBoardInput, ListAgentsInput, ListNodesInput, MoveBoardTaskInput, Node, NodeAgent, NodeAttachment, NodeBoard, NodeCitation, NodeDocument, NodeGraph, NodeGraphInput, NodeLink, NodeTable, NodeVersion, RedefineTableInput, ResolveNodeLinksInput, ResolveNodeLinksResult, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, RotateAgentKeyInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, ShareInput, ShareResult, UpdateBoardTaskInput, UpdateNodeInput, UpdateTableRowsInput, UpdateTableRowsResult } from "@anchrd/intel-contract";
1
+ import type { AddBoardTaskInput, AgentKeyRotated, AgentList, AppendTableRowsInput, AppendTableRowsResult, ArchiveNodeInput, BoardTaskResult, ConfigureBoardInput, ConfigureBoardResult, CreateAgentInput, CreatedAgent, CreateNodeInput, DefineTableInput, DeleteBoardTaskInput, DeleteBoardTaskResult, DeleteTableRowsInput, DeleteTableRowsResult, Flow, FlowVersion, GetAgentInput, GetBoardInput, ListAgentsInput, ListNodesInput, MoveBoardTaskInput, Node, NodeAgent, NodeAttachment, NodeBoard, NodeCitation, NodeDocument, NodeGraph, NodeGraphInput, NodeLink, NodeTable, NodeVersion, RedefineTableInput, RepairBoardTaskIdsInput, RepairBoardTaskIdsResult, ResolveNodeLinksInput, ResolveNodeLinksResult, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, RotateAgentKeyInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, ShareInput, ShareResult, UpdateBoardTaskInput, UpdateNodeInput, UpdateTableRowsInput, UpdateTableRowsResult } from "@anchrd/intel-contract";
2
2
  import type { SemanticIndex } from "../adapters/semantic-index/semantic-index.types.js";
3
3
  export interface Actor {
4
4
  id: string;
@@ -27,7 +27,7 @@ export interface NewTableVersion {
27
27
  idempotencyKey: string;
28
28
  auditId: string;
29
29
  }
30
- export type SnapshotOperation = "node.table_update" | "node.table_delete" | "node.table_redefine" | "node.board_configure" | "node.board_task_add" | "node.board_task_update" | "node.board_task_move" | "node.board_task_delete";
30
+ export type SnapshotOperation = "node.table_update" | "node.table_delete" | "node.table_redefine" | "node.board_configure" | "node.board_task_add" | "node.board_task_update" | "node.board_task_move" | "node.board_task_delete" | "node.board_task_repair";
31
31
  /**
32
32
  * One version that replaces the readable state, written only against the state it replaces.
33
33
  *
@@ -145,7 +145,21 @@ export interface NodeRepository {
145
145
  occurredAt: string;
146
146
  }): Promise<boolean>;
147
147
  searchVisible(actor: Actor, input: SearchInput): Promise<NodeCitation[]>;
148
- hydrateVisibleCitations(actor: Actor, nodeIds: string[], scopeId?: string): Promise<NodeCitation[]>;
148
+ /**
149
+ * `scopeId` cuts the candidates to one folder subtree, which is how the semantic half of a scoped
150
+ * search is narrowed (#126): the vector index answers over everything and this join is where the
151
+ * subtree — a relation in D1, not a value on a vector — is applied. The ACL still applies too.
152
+ *
153
+ * ⚠️ One entry per node, and it names WHICH chunk answered (anchrd/intel#301). The passage a
154
+ * searcher is shown is then that card rather than whichever one happens to be first in the board;
155
+ * a node whose winning chunk has no `node_vectors` row — every vector written before #301 — falls
156
+ * back on the first full-text passage, which is exactly what this returned before.
157
+ */
158
+ hydrateVisibleCitations(actor: Actor, hits: Array<{
159
+ nodeId: string;
160
+ chunkKey: string;
161
+ }>, scopeId?: string): Promise<NodeCitation[]>;
162
+ invalidateVectors(): Promise<void>;
149
163
  listCurrentVersionIds(input: {
150
164
  after: string | null;
151
165
  limit: number;
@@ -354,6 +368,15 @@ export interface NodeService {
354
368
  updateBoardTask(actor: Actor, input: UpdateBoardTaskInput): Promise<BoardTaskResult>;
355
369
  moveBoardTask(actor: Actor, input: MoveBoardTaskInput): Promise<BoardTaskResult>;
356
370
  deleteBoardTask(actor: Actor, input: DeleteBoardTaskInput): Promise<DeleteBoardTaskResult>;
371
+ /**
372
+ * The sixth, and the only one that is not about somebody's project (anchrd/intel#341).
373
+ *
374
+ * ⚠️ It names no task id, because a board that needs it holds two entries under one — the pair a
375
+ * bundle written before anchrd/intel#321 could carry in. Everything else here is unreachable for
376
+ * that second entry: the five operations above address a task by id and would take, write or
377
+ * remove both at once.
378
+ */
379
+ repairBoardTaskIds(actor: Actor, input: RepairBoardTaskIdsInput): Promise<RepairBoardTaskIdsResult>;
357
380
  getAgent(actor: Actor, input: GetAgentInput): Promise<NodeAgent>;
358
381
  /**
359
382
  * An agent node, its first definition, and the Gate Application it runs as (#182, D29).
@@ -435,12 +458,47 @@ export interface NodeIndexTarget {
435
458
  * task's own title, which is the text the FTS table weights highest.
436
459
  */
437
460
  export interface IndexChunk {
461
+ /**
462
+ * What this passage is called inside its node (anchrd/intel#301).
463
+ *
464
+ * `""` for a node that is one passage — every kind but `board` — and the task's own id for a card.
465
+ * The full-text index does not store it; the vector index is named by it, which is how a semantic
466
+ * hit can say WHICH card answered instead of only which board.
467
+ */
468
+ key: string;
438
469
  title: string;
439
470
  text: string;
440
471
  }
472
+ /**
473
+ * What the vector index holds for one chunk of one node (anchrd/intel#301).
474
+ *
475
+ * `fingerprint` is over the exact text that was embedded, so the next pass can tell an untouched
476
+ * card from a changed one without asking Vectorize — whose writes are asynchronous and would answer
477
+ * about the save before last. `passage` is what a searcher is shown when this vector is the hit.
478
+ */
479
+ export interface NodeVectorRecord {
480
+ chunkKey: string;
481
+ fingerprint: string;
482
+ passage: string;
483
+ }
441
484
  export interface NodeIndexRepository {
442
485
  getTarget(versionId: string): Promise<NodeIndexTarget | null>;
486
+ /**
487
+ * The node behind this version when the reason `getTarget` refused it is that the node has been
488
+ * ARCHIVED — and null for every other reason (anchrd/intel#348).
489
+ *
490
+ * ⚠️ The distinction is the whole method. `getTarget` answers null for two very different
491
+ * situations: the node is archived, or this version has been superseded by a later save. The
492
+ * first one means "take this node out of the vector index"; the second is a queue message that
493
+ * arrived late — the ordinary case, because Cloudflare Queues deliver at least once — and acting
494
+ * on it would delete the vectors of a node that is perfectly alive. So this asks for the archived
495
+ * case by name and never infers it from an absence.
496
+ */
497
+ archivedNodeId(versionId: string): Promise<string | null>;
443
498
  replace(target: NodeIndexTarget, chunks: IndexChunk[]): Promise<void>;
499
+ listVectors(nodeId: string): Promise<NodeVectorRecord[]>;
500
+ replaceVectors(target: NodeIndexTarget, records: NodeVectorRecord[]): Promise<void>;
501
+ deleteVectors(nodeId: string): Promise<void>;
444
502
  markIndexed(versionId: string, occurredAt: string): Promise<void>;
445
503
  markError(versionId: string, message: string, occurredAt: string): Promise<void>;
446
504
  }
@@ -260,7 +260,13 @@ export function createTools(deps) {
260
260
  if (!stored)
261
261
  return { portalConnected: false, items: [] };
262
262
  try {
263
- return { portalConnected: true, items: (await capabilities(who)).items };
263
+ const { items, serverOfTool } = await capabilities(who);
264
+ // ⚠️ Named only for a delegated caller, and it costs nothing there: the attribution had to
265
+ // be made anyway to cut the list. For an ordinary user it would mean a second portal
266
+ // request per call — `serverList` is not on their path — and their screen never asks the
267
+ // question. The runtime's is the one that does (#289).
268
+ const reached = who.delegation ? [...new Set(serverOfTool.values())].sort() : undefined;
269
+ return { portalConnected: true, items, ...(reached ? { reached } : {}) };
264
270
  }
265
271
  catch (error) {
266
272
  if (disconnected(error))
@@ -1,5 +1,13 @@
1
1
  -- #76. `context_policy` leaves the contract, the UI and every MCP answer. The COLUMN stays.
2
2
  --
3
+ -- ⚠️ HISTORY, and no longer an explanation of what is possible. The column was finally dropped by
4
+ -- `0018_no_context_policy_at_last.sql` (#86), through an ordinary migration file — the very thing
5
+ -- the conclusion at the bottom of this file says cannot be done. What changed is not D1 but the
6
+ -- recipe: `0005`, `0013` and `0016` worked out that the new table has to be created under the FINAL
7
+ -- name instead of renamed into place, and that `node_links` has to be carried out of the way
8
+ -- because it is the one child declared ON DELETE CASCADE. Read on for the three failures that
9
+ -- produced the lessons; read `0018` for the shape that works.
10
+ --
3
11
  -- ⚠️ That is not the intent, it is what D1 permits. SQLite cannot drop a column a CHECK names, and
4
12
  -- this one names itself. The way around it is a table rebuild, and `knowledge_nodes` carries six
5
13
  -- foreign keys, one of them from itself.
@@ -22,6 +30,13 @@
22
30
  -- A table rebuild with foreign keys is therefore not possible inside a migration file. It needs a
23
31
  -- session that drives the transaction itself.
24
32
  --
33
+ -- ⚠️ THAT CONCLUSION WAS WRONG, and the way it was wrong is worth more than the conclusion. The
34
+ -- three lessons above are all correct; what they did not contain was the fourth — do not RENAME at
35
+ -- all. Create the new table under the final name and insert the rows back under the name the
36
+ -- children have referenced the whole time, and there is nothing left for a deferred check to
37
+ -- complain about at COMMIT. `0005` found it, `0013` and `0016` repeated it, and `0018` used it to
38
+ -- drop this column at last (#86).
39
+ --
25
40
  -- What holds instead: the column sits in D1, nothing reads it, and `db.ts` writes a fixed value on
26
41
  -- insert because it is NOT NULL without a DEFAULT. The contract does not know it — for every
27
42
  -- consumer it is gone. What remains is one dead column, and that is the price of Intel running.
@@ -0,0 +1,38 @@
1
+ -- anchrd/intel#301: one vector per board card, and the record of which vectors a node has.
2
+ --
3
+ -- Vectorize is keyed by node id (`adapters/semantic-index`), so a board was ONE vector holding the
4
+ -- whole document while the full-text half had held one row per card since #285. "Where do I stand
5
+ -- with X" is the question a board exists to answer and it is an imprecise one: lexically it reaches
6
+ -- a card only where the searched word is written on it verbatim. A vector id therefore becomes
7
+ -- `<node id>#<task id>` for a board card and stays the bare node id for every other kind — no
8
+ -- existing vector changes its name, so no installation re-embeds its whole tree to get this.
9
+ --
10
+ -- This table is the D1 side of that index and nothing more: which vectors a node has, what text
11
+ -- each one was made from, and the passage a searcher is shown when that vector is the hit. It is
12
+ -- DERIVED like `node_fts` beside it, and `reindex` empties it so that a rebuild really rebuilds.
13
+ --
14
+ -- ⚠️ `fingerprint` is what keeps a board of three hundred cards from costing three hundred
15
+ -- embeddings per save. The indexing pass compares it against the text it is about to embed and
16
+ -- upserts only what changed; a row is written ONLY AFTER the upsert it describes succeeded. That is
17
+ -- why a fingerprint is stored rather than a timestamp: a pass that dies between the two leaves a
18
+ -- row missing, never a row claiming a vector that was never written, so the next pass repairs
19
+ -- itself instead of trusting a lie.
20
+ --
21
+ -- ⚠️ `chunk_key` is `''` for a node that has exactly one vector — every kind but `board`. NULL
22
+ -- would be the honest spelling and is the wrong one: NULLs are distinct from one another inside a
23
+ -- SQLite primary key, so two rows for the same node could both exist and neither would be found by
24
+ -- the other's write.
25
+ --
26
+ -- ⚠️ No foreign key on `node_id`, deliberately, and for the same reason `node_fts` has none: a
27
+ -- derived index must not be able to make a write to `nodes` fail, and every rebuild of `nodes`
28
+ -- (0005, 0013, 0016, 0018) has to carry each declared child through the detour those files describe.
29
+ -- A row that outlives its node is invisible anyway — hydration joins `nodes` and drops what is
30
+ -- archived or gone, exactly as it does for `node_fts`.
31
+ CREATE TABLE node_vectors (
32
+ node_id TEXT NOT NULL,
33
+ chunk_key TEXT NOT NULL,
34
+ version_id TEXT NOT NULL,
35
+ fingerprint TEXT NOT NULL,
36
+ passage TEXT NOT NULL,
37
+ PRIMARY KEY (node_id, chunk_key)
38
+ );
@@ -0,0 +1,90 @@
1
+ -- #86: `context_policy` leaves `nodes` for good.
2
+ --
3
+ -- #76 removed it from the contract, the UI and every MCP answer — for every consumer it has been
4
+ -- gone since. The COLUMN stayed because SQLite cannot drop one a CHECK names, and `db.ts` has been
5
+ -- writing the fixed value `'relevant'` into it ever since so that inserting a node works at all.
6
+ -- A dead column plus a line of code serving it: harmless, and ballast the next reader has to be
7
+ -- told about.
8
+ --
9
+ -- ⚠️ THE reason this ticket sat open for weeks is that three earlier attempts failed against
10
+ -- `--remote` while passing locally, and the file that recorded them (`0009_no_context_policy.sql`)
11
+ -- concluded that a table rebuild with foreign keys needs "a session that runs the transaction
12
+ -- itself, not a migration file". That conclusion is out of date, and the proof is in this
13
+ -- directory: `0005`, `0013` and `0016` each rebuilt this very table through an ordinary migration
14
+ -- file. `0016` did it against the live database on 2026-08-08 with 120 nodes, and every referencing
15
+ -- table came out with the same count it went in with.
16
+ --
17
+ -- ⚠️ One thing about that run is worth knowing before it is quoted as a precedent: the installation
18
+ -- held ZERO `node_links` rows, so the rescue below would have had nothing to rescue and the run
19
+ -- would have passed without exercising it at all. The one row it did carry was created for the
20
+ -- purpose, minutes before, by linking two throwaway documents. The proof was arranged, not found —
21
+ -- and the next person rebuilding this table has to arrange it again, because the installation still
22
+ -- has almost no links.
23
+ --
24
+ -- What the three of them worked out, and what this file copies rather than rediscovers:
25
+ --
26
+ -- 1. `PRAGMA foreign_keys = OFF` is IGNORED by D1 over the HTTP API. Locally miniflare obeys it,
27
+ -- so the integration test was green while the real database answered `FOREIGN KEY constraint
28
+ -- failed`. That is why the proof of a migration here is a run against `--remote`.
29
+ -- 2. The new table is created under the FINAL name rather than built beside the old one and
30
+ -- renamed over it. `ALTER TABLE ... RENAME` makes references FOLLOW the rename, so renaming
31
+ -- the old table out of the way quietly re-points every other table at a table about to be
32
+ -- dropped. Inserting the rows again under the name the children have referenced all along is
33
+ -- what settles them.
34
+ -- 3. `DROP TABLE` on a parent runs an implicit `DELETE FROM` first, and `node_links` is the one
35
+ -- child declared ON DELETE CASCADE — so that delete does not merely flag its rows, it REMOVES
36
+ -- them. They are carried out of the way and put back. That is a rescue, not a decision about
37
+ -- the data.
38
+ --
39
+ -- ⚠️ On an empty database neither detour is visible, because nothing points at anything. That is
40
+ -- how `0005`'s first version passed a green suite and then failed against the first database with
41
+ -- content in it, and why the proof for this file is a row count of every referencing table before
42
+ -- and after rather than a migration that merely ran.
43
+ PRAGMA defer_foreign_keys = TRUE;
44
+
45
+ -- Plain holding tables on purpose: no keys, no CHECKs, no foreign keys, and the column set taken
46
+ -- from whatever the live table has. Anything enforced here would only be enforced a second time on
47
+ -- the way back in, and a holding table that can reject a row is a holding table that can lose one.
48
+ CREATE TABLE nodes_carry AS SELECT * FROM nodes;
49
+ CREATE TABLE node_links_carry AS SELECT * FROM node_links;
50
+
51
+ DROP TABLE nodes;
52
+
53
+ -- The same table as `0016` left it, minus one column. Nothing else about it changes: same keys,
54
+ -- same CHECKs, same six kinds — a rebuild is the only way to drop the column, not an invitation to
55
+ -- change anything else while the table is open.
56
+ CREATE TABLE nodes (
57
+ id TEXT PRIMARY KEY NOT NULL,
58
+ parent_id TEXT REFERENCES nodes(id),
59
+ kind TEXT NOT NULL CHECK (kind IN ('folder', 'document', 'attachment', 'table', 'agent', 'board')),
60
+ title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 240),
61
+ description TEXT CHECK (description IS NULL OR length(description) <= 2000),
62
+ owner_id TEXT NOT NULL,
63
+ current_version_id TEXT,
64
+ created_at TEXT NOT NULL,
65
+ updated_at TEXT NOT NULL,
66
+ archived_at TEXT
67
+ );
68
+
69
+ -- Columns named on both sides rather than `SELECT *`: the holding table still HAS `context_policy`,
70
+ -- and a positional insert would either fail or, worse, shift every value one place to the left.
71
+ INSERT INTO nodes (
72
+ id, parent_id, kind, title, description, owner_id,
73
+ current_version_id, created_at, updated_at, archived_at
74
+ )
75
+ SELECT
76
+ id, parent_id, kind, title, description, owner_id,
77
+ current_version_id, created_at, updated_at, archived_at
78
+ FROM nodes_carry;
79
+
80
+ -- `OR IGNORE` because whether the cascade above actually fired is SQLite's business, not this
81
+ -- migration's: if it did, this puts the rows back; if it did not, each one is already present under
82
+ -- the same primary key and this is a no-op. Either way `node_links` ends up holding exactly what it
83
+ -- held before, which is the only outcome this statement is permitted to have.
84
+ INSERT OR IGNORE INTO node_links SELECT * FROM node_links_carry;
85
+
86
+ DROP TABLE nodes_carry;
87
+ DROP TABLE node_links_carry;
88
+
89
+ CREATE INDEX nodes_parent_idx ON nodes(parent_id, archived_at, title);
90
+ CREATE INDEX nodes_owner_idx ON nodes(owner_id, archived_at);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.12.5",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anchrd/gate-sdk": "^0.7.0",
46
- "@anchrd/intel-contract": "^0.10.0",
46
+ "@anchrd/intel-contract": "^0.12.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",