@anchrd/intel-api 0.12.5 → 0.13.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.
@@ -19,6 +19,23 @@ const ApiOrigin = "https://api.cloudflare.com/client/v4";
19
19
  */
20
20
  const MaxPages = 20;
21
21
  const PerPage = 50;
22
+ /**
23
+ * ⚠️ Cloudflare's ceiling on `/ai/models/search`, and a DIFFERENT number from `PerPage` above —
24
+ * the limit belongs to the endpoint, not to the account. Neither may be copied onto the other's
25
+ * call: 50 on the model catalog halves it, 100 on the log is refused outright.
26
+ *
27
+ * And the two fail in opposite ways, of which this is the worse one. The log endpoint REFUSES with
28
+ * `HTTP 400 Number must be less than or equal to 50` — loud, and found in a day (#294). This one
29
+ * IGNORES: measured against the live account (#297), `per_page=200` and `per_page=1000` both answer
30
+ * `HTTP 200` with no error and `result_info.per_page: 100`.
31
+ *
32
+ * ⚠️ `result_info.total_count` cannot be used to notice a short answer either. The same account
33
+ * reports `total_count: 286` and returns 61 entries on page 1, with page 2 empty — a reader that
34
+ * paginated on that figure would loop over empty pages and call the result partial. The truthful
35
+ * signal is a page that came back FULL, which the log reader already uses and this one does not
36
+ * yet (anchrd/intel#330).
37
+ */
38
+ const ModelPerPage = 100;
22
39
  /**
23
40
  * The gateway's log entry, read tolerantly.
24
41
  *
@@ -184,7 +201,7 @@ export function createCloudflareApi(deps) {
184
201
  },
185
202
  async workersAiModels() {
186
203
  const body = await get(`/accounts/${encodeURIComponent(deps.accountId)}/ai/models/search`, {
187
- per_page: "200",
204
+ per_page: String(ModelPerPage),
188
205
  hide_experimental: "true",
189
206
  });
190
207
  const parsed = ModelResponse.safeParse(body);
@@ -24,9 +24,56 @@ function metadataCandidates(resourceUrl, challenge) {
24
24
  function resourceMetadataChallenge(header) {
25
25
  return header?.match(/resource_metadata="([^"]+)"/i)?.[1] ?? null;
26
26
  }
27
+ /**
28
+ * ⚠️ `issuer` is REQUIRED by RFC 8414 §2 and by OpenID Connect Discovery, and it is read here for one
29
+ * reason: it is the only thing that says WHO a metadata document describes. Two of the URLs below are
30
+ * bare-root guesses, and a bare root under a multi-tenant authorization server answers for a
31
+ * different tenant — a perfectly valid document about somebody else.
32
+ */
27
33
  const AuthorizationServerMetadata = z.looseObject({
34
+ issuer: z.string().nullish(),
28
35
  scopes_supported: z.array(z.string()).nullish(),
29
36
  });
37
+ /**
38
+ * Where an authorization server may publish its metadata, in the order worth asking.
39
+ *
40
+ * ⚠️ One candidate was never a decision, it was an omission — `metadataCandidates` above tries three
41
+ * and `withAlgorithmFallback` tries two. A server that publishes only OIDC discovery read as "names
42
+ * no scopes", which is #97 at a different server class and with the same silent symptom (#303).
43
+ *
44
+ * ⚠️ The three spellings are not interchangeable, and the third is the one that is easy to miss.
45
+ * RFC 8414 §3.1 INSERTS the issuer's path between the well-known prefix and nothing else; OpenID
46
+ * Connect Discovery 1.0 APPENDS its suffix to the issuer instead. A realm-style issuer
47
+ * (`https://as.example/realms/x`) publishes at `…/realms/x/.well-known/openid-configuration` and at
48
+ * neither of the other two — which is exactly the form `client.discovery` asks for further down this
49
+ * file, so the same server was reachable for registration and invisible here.
50
+ */
51
+ function authorizationServerCandidates(issuer) {
52
+ const url = new URL(issuer);
53
+ const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, "");
54
+ return [
55
+ // RFC 8414 with the path inserted. First, because it is the shape the MCP portals publish.
56
+ `${url.origin}/.well-known/oauth-authorization-server${path}`,
57
+ // The same document at the bare root, for an issuer WITH a path that publishes it there anyway.
58
+ `${url.origin}/.well-known/oauth-authorization-server`,
59
+ // OpenID Connect Discovery 1.0, appended to the issuer — the realm-style form.
60
+ `${url.origin}${path}/.well-known/openid-configuration`,
61
+ // The same document read the RFC 8414 way, and at the bare root.
62
+ `${url.origin}/.well-known/openid-configuration${path}`,
63
+ `${url.origin}/.well-known/openid-configuration`,
64
+ ].filter((value, index, values) => values.indexOf(value) === index);
65
+ }
66
+ /** Two issuer identifiers are the same server, compared the way a URL says so and not as strings. */
67
+ function sameIssuer(published, wanted) {
68
+ if (!published)
69
+ return false;
70
+ try {
71
+ return new URL(published).href.replace(/\/$/, "") === new URL(wanted).href.replace(/\/$/, "");
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
30
77
  /**
31
78
  * The scopes this authorization server says it understands, or `null` when it names none.
32
79
  *
@@ -41,16 +88,29 @@ const AuthorizationServerMetadata = z.looseObject({
41
88
  * Intel then asked it for `offline_access`, a scope it never published (#97).
42
89
  */
43
90
  async function publishedScopes(fetcher, issuer) {
44
- const url = new URL(issuer);
45
- const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, "");
46
- const response = await fetcher(`${url.origin}/.well-known/oauth-authorization-server${path}`, {
47
- signal: AbortSignal.timeout(RequestTimeoutMs),
48
- headers: { accept: "application/json" },
49
- }).catch(() => null);
50
- if (!response?.ok)
51
- return null;
52
- const metadata = AuthorizationServerMetadata.safeParse(await response.json().catch(() => null));
53
- return metadata.success ? (metadata.data.scopes_supported ?? null) : null;
91
+ for (const candidate of authorizationServerCandidates(issuer)) {
92
+ const response = await fetcher(candidate, {
93
+ signal: AbortSignal.timeout(RequestTimeoutMs),
94
+ headers: { accept: "application/json" },
95
+ }).catch(() => null);
96
+ if (!response?.ok)
97
+ continue;
98
+ const metadata = AuthorizationServerMetadata.safeParse(await response.json().catch(() => null));
99
+ if (!metadata.success)
100
+ continue;
101
+ // ⚠️ The document has to be ABOUT this issuer, and that check is what makes the bare-root
102
+ // candidates safe to ask at all. Under a multi-tenant server the root answers for a different
103
+ // tenant, and taking its `scopes_supported` would ask THIS server for a scope it never published
104
+ // — the very rule this function exists to keep, broken by the fallback added to protect it.
105
+ // The resource loop further down does the same thing for the same reason.
106
+ if (!sameIssuer(metadata.data.issuer, issuer))
107
+ continue;
108
+ // ⚠️ A document that answered ABOUT THIS SERVER and named no scopes ends the search. Reading on
109
+ // would let the next candidate answer a question this server has already answered — "I publish
110
+ // none" — and the documents of one issuer may disagree about a field RFC 8414 makes optional.
111
+ return metadata.data.scopes_supported ?? null;
112
+ }
113
+ return null;
54
114
  }
55
115
  /**
56
116
  * Of the scopes Intel would like, the ones this server published — joined, or absent.
@@ -1,5 +1,6 @@
1
1
  import { AgentDefinition, AgentMediaType, BlockNoteMediaType, BoardDocument, BoardMediaType, BundleImportResult, BundleManifest, BundleManifestFilename, DocumentLinkInlineType, FlowGraph, TableMediaType, } from "@anchrd/intel-contract";
2
2
  import { Unzip, UnzipInflate, Zip, ZipDeflate, ZipPassThrough } from "fflate";
3
+ import { repeatedBoardId, upgradeStoredBoard } from "../nodes/board/board.js";
3
4
  import { documentLinkTargets } from "../nodes/document-links/document-links.js";
4
5
  import { parseCsv } from "../shared/csv/csv.js";
5
6
  import { IntelError } from "../shared/intel-error/intel-error.js";
@@ -935,11 +936,34 @@ export function createBundle(deps) {
935
936
  if (text.trim().length > 0) {
936
937
  let document;
937
938
  try {
938
- document = BoardDocument.parse(JSON.parse(text));
939
+ // Same upgrade the stored read does (anchrd/intel#311): a bundle exported before
940
+ // `terminal` existed is exactly an old board in a file, and importing it must not be
941
+ // the one way to lose a board.
942
+ document = BoardDocument.parse(upgradeStoredBoard(JSON.parse(text)));
939
943
  }
940
944
  catch {
941
945
  throw new IntelError(400, "import_invalid_board", `Bundle entry is not a board: ${entry.path}`);
942
946
  }
947
+ /**
948
+ * ⚠️ Refused by name, and refused rather than folded (anchrd/intel#321).
949
+ *
950
+ * A board arriving in a file is the ONE place a document somebody else wrote becomes
951
+ * truth here — everywhere else a task id is minted by `deps.id()` and a status list
952
+ * goes through `ConfigureBoardInput`, which has demanded distinct ids since #285. Two
953
+ * tasks under one id threw `graph.addNode` out of the board's graph view, and two
954
+ * statuses under one id drew the same cards in two lanes with a drop that had two
955
+ * targets meaning one.
956
+ *
957
+ * Outside the catch above on purpose: swallowed into `import_invalid_board` this would
958
+ * read as "that file is not a board", and whoever is holding the file would look for
959
+ * the wrong thing. `repeatedBoardId` names the id, which is the line they have to fix.
960
+ */
961
+ const repeated = repeatedBoardId(document);
962
+ if (repeated !== null) {
963
+ throw new IntelError(400, "import_duplicate_board_id", repeated.list === "tasks"
964
+ ? `Bundle entry names the task “${repeated.id}” twice: ${entry.path}`
965
+ : `Bundle entry names the status “${repeated.id}” twice: ${entry.path}`);
966
+ }
943
967
  // ⚠️ `references` are remapped the way a document's inline links are, and `dependsOn`
944
968
  // and `parentId` are NOT (#285). A task's references point at NODES, which the import
945
969
  // renumbers; its dependencies and its parent point at tasks inside this very file,
@@ -1,4 +1,5 @@
1
1
  import { BlockNoteDocument, BlockNoteMediaType, BoardDocument } from "@anchrd/intel-contract";
2
+ import { upgradeStoredBoard } from "../nodes/board/board.js";
2
3
  export class PermanentIndexingError extends Error {
3
4
  }
4
5
  function indexText(mediaType, content) {
@@ -33,7 +34,17 @@ function boardChunks(content) {
33
34
  catch (error) {
34
35
  throw new PermanentIndexingError(`Node version content is not valid JSON: ${error instanceof Error ? error.message : "unknown parse failure"}`);
35
36
  }
36
- const board = BoardDocument.safeParse(parsed);
37
+ /**
38
+ * ⚠️ Upgraded first, exactly like `parseStoredBoard` — this is the SECOND place a stored board
39
+ * body is parsed, and it reads bodies nobody has rewritten since (anchrd/intel#318).
40
+ *
41
+ * `reindex` walks every current version in the installation, so an old board arrives here as it
42
+ * was written: without `terminal` (#311) or with a repeated `dependsOn`. Without the upgrade the
43
+ * refusal below is PERMANENT — the version is marked failed and never retried — so that board
44
+ * would simply stop being findable, quietly, while its screen still worked. A rule added at one
45
+ * parse site and not the other is how a fix looks complete and is not.
46
+ */
47
+ const board = BoardDocument.safeParse(upgradeStoredBoard(parsed));
37
48
  if (!board.success) {
38
49
  throw new PermanentIndexingError("Node version content is not a valid board document");
39
50
  }
package/dist/mcp/mcp.js CHANGED
@@ -540,7 +540,7 @@ export async function handleMcp(request, deps) {
540
540
  */
541
541
  server.registerTool("board_configure", {
542
542
  title: "Configure board statuses",
543
- description: 'Write the board\'s whole status list, in the order it should be drawn. Adding, renaming and reordering columns are all this call. The "archived" status is part of every board and cannot be removed, and a status that tasks still sit in cannot be removed either.',
543
+ description: 'Write the board\'s whole status list, in the order it should be drawn. Adding, renaming and reordering columns are all this call. The "archived" status is part of every board and cannot be removed, and a status that tasks still sit in cannot be removed either. Set "terminal" on a column to say that standing in it means the work is finished — that is what decides whether a task waiting on another is still blocked. Several columns may be terminal; "archived" always is and cannot be set otherwise. Omitting it leaves an ordinary column not terminal.',
544
544
  inputSchema: ConfigureBoardInput,
545
545
  annotations: {
546
546
  title: "Configure board statuses",
@@ -553,7 +553,7 @@ export async function handleMcp(request, deps) {
553
553
  }, async (input) => text(await deps.nodes.configureBoard(actor, input)));
554
554
  server.registerTool("board_task_add", {
555
555
  title: "Add board task",
556
- description: "Add one task to a board. The server assigns its id and its place in the order; name afterTaskId or beforeTaskId to put it between two cards, or neither to put it last in its column. parentId makes it a subtask of another task on the same board, dependsOn names tasks of the same board it waits for, and references names Intel nodes. Needs no baseVersionId: tasks are addressed by id, so parallel writers do not collide.",
556
+ description: "Add one task to a board. The server assigns its id and its place in the order; name afterTaskId or beforeTaskId to put it between two cards, or neither to put it last in its column. parentId makes it a subtask of another task on the same board, dependsOn names tasks of the same board it waits for, and references names Intel nodes. dependsOn, labels and references are sets: naming the same task, word or node twice is refused rather than quietly folded together. Needs no baseVersionId: tasks are addressed by id, so parallel writers do not collide.",
557
557
  inputSchema: AddBoardTaskInput,
558
558
  annotations: {
559
559
  title: "Add board task",
@@ -565,7 +565,7 @@ export async function handleMcp(request, deps) {
565
565
  }, async (input) => text(await deps.nodes.addBoardTask(actor, input)));
566
566
  server.registerTool("board_task_update", {
567
567
  title: "Update board task",
568
- description: "Change what a task says: title, assignee, labels, dates, dependencies, description or references. Where a task SITS — its status, its parent, its order — is board_task_move instead. Every named field replaces its current value whole; fields that are not named stay as they are.",
568
+ description: "Change what a task says: title, assignee, labels, dates, dependencies, description or references. Where a task SITS — its status, its parent, its order — is board_task_move instead. Every named field replaces its current value whole; fields that are not named stay as they are. dependsOn, labels and references are sets: appending to a list this task already holds is refused rather than quietly folded together, so read the current value before you write it back.",
569
569
  inputSchema: UpdateBoardTaskInput,
570
570
  annotations: {
571
571
  title: "Update board task",
@@ -1,4 +1,48 @@
1
+ import { type BoardDocument } from "@anchrd/intel-contract";
1
2
  import type { BoardDeps, BoardOperations } from "./board.types.js";
3
+ /**
4
+ * A stored board brought up to the current schema, before it is validated.
5
+ *
6
+ * ⚠️ It runs before `BoardDocument.parse`, on `unknown`, because after that parse a missing field or
7
+ * a repeated id is already a refusal — and `parseStoredBoard` is deliberately loud
8
+ * (`board_unreadable`), so an un-upgraded board would not read as "old", it would read as corrupt.
9
+ *
10
+ * The upgrade is persisted by the next write of any kind, because every write serialises the whole
11
+ * document. Nothing has to be migrated ahead of time and nothing has to be re-migrated.
12
+ */
13
+ export declare function upgradeStoredBoard(parsed: unknown): unknown;
14
+ /**
15
+ * The first id a board document uses for two different things (anchrd/intel#321).
16
+ *
17
+ * ⚠️ Deliberately NOT a rule on `BoardDocument`, and that is the whole decision. A schema rule would
18
+ * reach every parse of a stored body — `parseStoredBoard`, `indexing.ts`, the link reader — and a
19
+ * board imported before this existed would stop answering with `board_unreadable`: the whole board
20
+ * gone over something that costs one view. It is the trap #311 walked into, and #318 walked into
21
+ * from the other side.
22
+ *
23
+ * ⚠️ And NOT folded in `upgradeStoredBoard` either, which is where the sibling rules of #318 went. A
24
+ * repeated `dependsOn`, label or reference carries no information, so dropping the second mention
25
+ * loses nothing. Two tasks under one id are two whole tasks — titles, dates, descriptions — and a
26
+ * fold on the READ is written back by the next save of any kind, because every write serialises the
27
+ * whole document. That is one task gone for good, for every board, without anybody having asked.
28
+ * The UI folds the same pair for a DRAWING (`orderedTasks`), which costs nothing: the document keeps
29
+ * both, and repairing the pair brings the second card back.
30
+ *
31
+ * ⚠️ What this does NOT rescue, and it is worth knowing: `replaced` matches a task by id, so the
32
+ * first `board_task_update` against such a pair writes the SAME task into both entries. The stored
33
+ * board is then two identical tasks rather than two different ones. That is a consequence of
34
+ * addressing a board by task id at all (#285) and predates this rule; the reason the rule is at the
35
+ * import is to stop the pair from existing, not to make it survivable.
36
+ *
37
+ * So it is asked exactly where there is a caller to answer: the bundle import, the one door a board
38
+ * document written elsewhere comes in through. Everything else mints task ids itself (`deps.id()`)
39
+ * and takes its status list through `ConfigureBoardInput`, which has demanded distinct ids since
40
+ * #285.
41
+ */
42
+ export declare function repeatedBoardId(document: BoardDocument): {
43
+ list: "statuses" | "tasks";
44
+ id: string;
45
+ } | null;
2
46
  /**
3
47
  * Every board operation, applied to the document rather than to the file (#285).
4
48
  *
@@ -1,6 +1,170 @@
1
1
  import { ArchivedBoardStatusId, BoardDefaultStatuses, BoardMaxTaskDepth, } from "@anchrd/intel-contract";
2
2
  import { generateKeyBetween } from "fractional-indexing";
3
3
  import { IntelError } from "../../shared/intel-error/intel-error.js";
4
+ /**
5
+ * Statuses that answer whether standing in them means finished (anchrd/intel#311).
6
+ *
7
+ * ⚠️ This is the ONE place the old positional rule survives, and it survives as a data migration
8
+ * rather than as a reading. A board written before `terminal` existed has no answer to "which
9
+ * columns mean finished", and the honest answer is the one that board already behaved as: the last
10
+ * column before `archived`, plus the shelf. Filling `false` everywhere instead would quietly declare
11
+ * that nothing on any existing board is done — every finished task would become an open blocker
12
+ * overnight. Filling `true` everywhere would be the same lie the other way round.
13
+ *
14
+ * ⚠️ It touches only what is missing. A status that already carries the flag keeps it, whatever the
15
+ * position rule would have said — otherwise the migration would undo somebody's configuration every
16
+ * time their board was read.
17
+ *
18
+ * `undefined` means "nothing to do", which is what keeps an untouched document identical rather
19
+ * than merely equal.
20
+ */
21
+ function upgradedStatuses(statuses) {
22
+ if (!Array.isArray(statuses))
23
+ return undefined;
24
+ const entries = statuses.filter((status) => typeof status === "object" && status !== null);
25
+ if (entries.every((status) => "terminal" in status))
26
+ return undefined;
27
+ // The end of the work as the board behaved before the flag: the last column that is not the shelf,
28
+ // read in the stored order rather than in array order, because `order` is what a surface draws by.
29
+ const working = entries
30
+ .filter((status) => status.id !== ArchivedBoardStatusId)
31
+ .sort((left, right) => Number(left.order ?? 0) - Number(right.order ?? 0));
32
+ const lastWorking = working.at(-1)?.id;
33
+ return statuses.map((status) => {
34
+ if (typeof status !== "object" || status === null || "terminal" in status)
35
+ return status;
36
+ const entry = status;
37
+ return {
38
+ ...entry,
39
+ terminal: entry.id === ArchivedBoardStatusId || entry.id === lastWorking,
40
+ };
41
+ });
42
+ }
43
+ /**
44
+ * The three task lists that have to be distinct, and how a stored entry is recognised again.
45
+ *
46
+ * ⚠️ `labels` are compared TRIMMED and the two id lists are not, and that mirrors the schema
47
+ * exactly: `BoardTaskLabel` carries `.trim()`, `BoardTaskId` and `IntelId` do not. Comparing raw
48
+ * here would leave `["Bug ", "Bug"]` untouched — and zod, which trims each entry BEFORE the
49
+ * distinctness check runs on the array, would then see one word twice and refuse the board. That is
50
+ * precisely the `board_unreadable` this function exists to prevent, arrived at through the fold.
51
+ */
52
+ const distinctTaskLists = [
53
+ { field: "dependsOn", identity: (value) => value },
54
+ {
55
+ field: "labels",
56
+ identity: (value) => (typeof value === "string" ? value.trim() : value),
57
+ },
58
+ { field: "references", identity: (value) => value },
59
+ ];
60
+ /**
61
+ * Tasks whose lists name nothing twice (anchrd/intel#318).
62
+ *
63
+ * ⚠️ `dependsOn`, `labels` and `references` are refused at the write boundary now, and that refusal
64
+ * reaches nothing already in R2 — boards have been writable since `c29fd12` and `board_task_update`
65
+ * took a repeat without complaint. A stored board carrying one would meet the strict `BoardTask` and
66
+ * answer `board_unreadable`: the whole board lost over a value that has no reading, which is the
67
+ * trap #311 walked into from the other side.
68
+ *
69
+ * ⚠️ Folded here and refused there, and the difference is not inconsistency — it is who is being
70
+ * answered. A caller handed back a shorter list than it sent learns nothing and repeats itself; a
71
+ * stored document has no caller to tell, and dropping the board instead would punish a reader for
72
+ * what some writer did months ago.
73
+ *
74
+ * ⚠️ The FIRST mention keeps its place. Every surface draws these lists in the order they stand in.
75
+ */
76
+ function dedupedTaskLists(tasks) {
77
+ if (!Array.isArray(tasks))
78
+ return undefined;
79
+ let folded = false;
80
+ const next = tasks.map((task) => {
81
+ if (typeof task !== "object" || task === null)
82
+ return task;
83
+ const entry = task;
84
+ const lists = distinctTaskLists.flatMap(({ field, identity }) => {
85
+ const list = entry[field];
86
+ if (!Array.isArray(list))
87
+ return [];
88
+ const seen = new Set();
89
+ const distinct = list.filter((value) => {
90
+ const key = identity(value);
91
+ if (seen.has(key))
92
+ return false;
93
+ seen.add(key);
94
+ return true;
95
+ });
96
+ return distinct.length === list.length ? [] : [[field, distinct]];
97
+ });
98
+ if (lists.length === 0)
99
+ return task;
100
+ folded = true;
101
+ return { ...entry, ...Object.fromEntries(lists) };
102
+ });
103
+ return folded ? next : undefined;
104
+ }
105
+ /**
106
+ * A stored board brought up to the current schema, before it is validated.
107
+ *
108
+ * ⚠️ It runs before `BoardDocument.parse`, on `unknown`, because after that parse a missing field or
109
+ * a repeated id is already a refusal — and `parseStoredBoard` is deliberately loud
110
+ * (`board_unreadable`), so an un-upgraded board would not read as "old", it would read as corrupt.
111
+ *
112
+ * The upgrade is persisted by the next write of any kind, because every write serialises the whole
113
+ * document. Nothing has to be migrated ahead of time and nothing has to be re-migrated.
114
+ */
115
+ export function upgradeStoredBoard(parsed) {
116
+ if (typeof parsed !== "object" || parsed === null)
117
+ return parsed;
118
+ const document = parsed;
119
+ const statuses = upgradedStatuses(document.statuses);
120
+ const tasks = dedupedTaskLists(document.tasks);
121
+ if (statuses === undefined && tasks === undefined)
122
+ return parsed;
123
+ return {
124
+ ...document,
125
+ ...(statuses !== undefined && { statuses }),
126
+ ...(tasks !== undefined && { tasks }),
127
+ };
128
+ }
129
+ /**
130
+ * The first id a board document uses for two different things (anchrd/intel#321).
131
+ *
132
+ * ⚠️ Deliberately NOT a rule on `BoardDocument`, and that is the whole decision. A schema rule would
133
+ * reach every parse of a stored body — `parseStoredBoard`, `indexing.ts`, the link reader — and a
134
+ * board imported before this existed would stop answering with `board_unreadable`: the whole board
135
+ * gone over something that costs one view. It is the trap #311 walked into, and #318 walked into
136
+ * from the other side.
137
+ *
138
+ * ⚠️ And NOT folded in `upgradeStoredBoard` either, which is where the sibling rules of #318 went. A
139
+ * repeated `dependsOn`, label or reference carries no information, so dropping the second mention
140
+ * loses nothing. Two tasks under one id are two whole tasks — titles, dates, descriptions — and a
141
+ * fold on the READ is written back by the next save of any kind, because every write serialises the
142
+ * whole document. That is one task gone for good, for every board, without anybody having asked.
143
+ * The UI folds the same pair for a DRAWING (`orderedTasks`), which costs nothing: the document keeps
144
+ * both, and repairing the pair brings the second card back.
145
+ *
146
+ * ⚠️ What this does NOT rescue, and it is worth knowing: `replaced` matches a task by id, so the
147
+ * first `board_task_update` against such a pair writes the SAME task into both entries. The stored
148
+ * board is then two identical tasks rather than two different ones. That is a consequence of
149
+ * addressing a board by task id at all (#285) and predates this rule; the reason the rule is at the
150
+ * import is to stop the pair from existing, not to make it survivable.
151
+ *
152
+ * So it is asked exactly where there is a caller to answer: the bundle import, the one door a board
153
+ * document written elsewhere comes in through. Everything else mints task ids itself (`deps.id()`)
154
+ * and takes its status list through `ConfigureBoardInput`, which has demanded distinct ids since
155
+ * #285.
156
+ */
157
+ export function repeatedBoardId(document) {
158
+ for (const list of ["statuses", "tasks"]) {
159
+ const seen = new Set();
160
+ for (const entry of document[list]) {
161
+ if (seen.has(entry.id))
162
+ return { list, id: entry.id };
163
+ seen.add(entry.id);
164
+ }
165
+ }
166
+ return null;
167
+ }
4
168
  /**
5
169
  * Every board operation, applied to the document rather than to the file (#285).
6
170
  *
@@ -260,6 +424,11 @@ export function createBoard(deps) {
260
424
  id: status.id,
261
425
  label: status.label,
262
426
  order: index,
427
+ // ⚠️ The shelf is always terminal and the boundary refuses an explicit `false` for it, so
428
+ // this is not a silent correction of something a caller asked for — it is the same fact
429
+ // stated where the document is built. Everything else defaults to "not finished": a new
430
+ // column is work, and a column that ends work is a thing somebody says on purpose (#311).
431
+ terminal: status.id === ArchivedBoardStatusId ? true : (status.terminal ?? false),
263
432
  }));
264
433
  return { statuses: next, tasks: board.tasks };
265
434
  },
@@ -1,4 +1,5 @@
1
1
  import { BlockNoteDocument, BlockNoteMediaType, BoardDocument, BoardMediaType, DocumentLinkInlineType, } from "@anchrd/intel-contract";
2
+ import { upgradeStoredBoard } from "../board/board.js";
2
3
  function isRecord(value) {
3
4
  return typeof value === "object" && value !== null;
4
5
  }
@@ -52,7 +53,17 @@ export function documentLinkTargets(mediaType, content) {
52
53
  }
53
54
  const found = new Set();
54
55
  if (mediaType === BoardMediaType) {
55
- const board = BoardDocument.safeParse(parsed);
56
+ /**
57
+ * ⚠️ Upgraded first, like `parseStoredBoard` and `indexing.ts` — this is the FOURTH place a
58
+ * board body is parsed, and the only one that cannot complain (anchrd/intel#321).
59
+ *
60
+ * A board it fails to parse simply has no links, which reads exactly like a board that points
61
+ * at nothing — and the reconciliation this feeds removes the links a body no longer names, so
62
+ * an unparsed old board would quietly leave the link graph. Every caller hands it a body written
63
+ * moments earlier today, so it has never met one; the upgrade is what keeps that from being the
64
+ * only reason.
65
+ */
66
+ const board = BoardDocument.safeParse(upgradeStoredBoard(parsed));
56
67
  if (!board.success)
57
68
  return [];
58
69
  for (const task of board.data.tasks) {
@@ -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.
@@ -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))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.12.5",
3
+ "version": "0.13.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.11.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",