@anchrd/intel-api 0.27.0 → 0.28.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.
@@ -0,0 +1,166 @@
1
+ import { IntelError } from "../shared/intel-error/intel-error.js";
2
+ // What a board with no configuration answers with. A board is usable the moment it is created, and
3
+ // nothing asks whoever made it to define columns before filing the first card.
4
+ export const DEFAULT_COLUMNS = [
5
+ { id: "todo", title: "Offen", terminal: false },
6
+ { id: "doing", title: "Läuft", terminal: false },
7
+ { id: "done", title: "Fertig", terminal: true },
8
+ ];
9
+ // The column a card lands in when nobody said and the board has none configured. One export rather
10
+ // than the literal `"todo"` in three places: the fallback exists FOR the unconfigured board, so a
11
+ // copy of it would drift exactly where it is the only thing deciding.
12
+ export const FIRST_DEFAULT_COLUMN = DEFAULT_COLUMNS[0].id;
13
+ export function createBoards(deps) {
14
+ /**
15
+ * ⚠️ The one place "which board, and may this actor DO THIS to it" is answered — and the verb is
16
+ * the whole question. It asked `read` for every caller once, including the three that were about
17
+ * to write: a board shared read-only could have its columns replaced and its cards moved. The
18
+ * refusal existed and asked the wrong thing, which is the harder failure to see.
19
+ *
20
+ * A `404` rather than a `403`, on purpose: "this board exists but you may not touch it" is itself
21
+ * information about somebody else's tree.
22
+ */
23
+ const columnsOrRefuse = async (actor, boardId, verb) => {
24
+ const columns = await deps.boards.columnsOf(actor, boardId, verb);
25
+ if (columns === null) {
26
+ throw new IntelError(404, "board_not_found", "No board with this id is readable for you.");
27
+ }
28
+ return columns.length > 0 ? columns : DEFAULT_COLUMNS.map((column) => column.id);
29
+ };
30
+ const viewOrRefuse = async (actor, input) => {
31
+ const view = await deps.boards.read(actor, input);
32
+ if (view === null) {
33
+ throw new IntelError(404, "board_not_found", "No board with this id is readable for you.");
34
+ }
35
+ // A board that was never configured answers with the default columns rather than none. An empty
36
+ // column list would render as a board with no columns at all — a screen with nowhere to put a
37
+ // card, which looks broken rather than new.
38
+ return view.columns.length > 0 ? view : { ...view, columns: [...DEFAULT_COLUMNS] };
39
+ };
40
+ const requireColumn = (columns, status) => {
41
+ if (columns.includes(status))
42
+ return;
43
+ throw new IntelError(400, "unknown_column", `This board has no column \`${status}\`. Its columns are: ${columns.join(", ")}.`);
44
+ };
45
+ return {
46
+ async get(actor, input) {
47
+ return await viewOrRefuse(actor, input);
48
+ },
49
+ async update(actor, input) {
50
+ await columnsOrRefuse(actor, input.boardId, "write");
51
+ // ⚠️ Duplicate ids are refused rather than deduplicated. Two columns with one id means every
52
+ // card in them lands in whichever the view draws first, and silently dropping one would
53
+ // discard a column somebody just wrote.
54
+ const ids = input.columns.map((column) => column.id);
55
+ const duplicate = ids.find((id, index) => ids.indexOf(id) !== index);
56
+ if (duplicate !== undefined) {
57
+ throw new IntelError(400, "duplicate_column", `The column id \`${duplicate}\` appears more than once.`);
58
+ }
59
+ await deps.boards.setColumns(input.boardId, JSON.stringify(input.columns), deps.now().toISOString());
60
+ return await viewOrRefuse(actor, { boardId: input.boardId, includeArchived: false });
61
+ },
62
+ async createTask(actor, input) {
63
+ const columns = await columnsOrRefuse(actor, input.boardId, "write");
64
+ // Absent means the first column — a caller who does not care where a task starts should not
65
+ // have to read the board first. A named one is checked, because a typo would otherwise create
66
+ // a card in a column no view draws.
67
+ const status = input.status ?? columns[0] ?? DEFAULT_COLUMNS[0].id;
68
+ if (input.status !== undefined)
69
+ requireColumn(columns, status);
70
+ // ⚠️ Through the node service, not a second insert. The task gets the same authorization
71
+ // check, the same audit row and the same version chain every other node gets — and that audit
72
+ // row is what `anchrd/signals` reads (#620).
73
+ const node = await deps.nodes.create(actor, {
74
+ parentId: input.boardId,
75
+ kind: "task",
76
+ title: input.title,
77
+ description: null,
78
+ idempotencyKey: input.idempotencyKey,
79
+ });
80
+ /**
81
+ * ⚠️ **`update`, not `attach` — the row already exists by the time this line runs**, and
82
+ * getting that wrong is silent. `nodes.create` files every new task through `attachToBoard`,
83
+ * with the board's first column and no fields; a second `INSERT` here is refused by the
84
+ * primary key, `ON CONFLICT DO NOTHING` swallows the refusal, and the caller's `status`,
85
+ * assignee and dates are **discarded without an error**. The card lands in the wrong column
86
+ * and nothing anywhere says why.
87
+ *
88
+ * It was written as an insert first, and three tests caught it: a card created in `doing`
89
+ * came back in `todo`. The lesson is the shape rather than the line — one row, one writer,
90
+ * and the writer is the node path.
91
+ */
92
+ /**
93
+ * ⚠️ **The position is computed only when the card is not already in that column**, and that
94
+ * is what makes a retry safe. `deps.nodes.create` honours the idempotency key and answers with
95
+ * the SAME node on a replay; recomputing here would push the card one place further on every
96
+ * attempt — a client retrying a timeout would watch its card walk down the column, with every
97
+ * call succeeding.
98
+ *
99
+ * The other fields are rewritten with the same values, which is idempotent by construction.
100
+ */
101
+ const filed = await deps.boards.taskRow(actor, node.id, "write");
102
+ await deps.boards.updateTask(node.id, {
103
+ status,
104
+ assignee_id: input.assigneeId,
105
+ labels_json: JSON.stringify(input.labels),
106
+ start_date: input.startDate,
107
+ due_date: input.dueDate,
108
+ depends_on: input.dependsOn,
109
+ ...(filed?.status === status
110
+ ? {}
111
+ : { position: await deps.boards.nextPosition(input.boardId, status) }),
112
+ }, deps.now().toISOString());
113
+ return await viewOrRefuse(actor, { boardId: input.boardId, includeArchived: false });
114
+ },
115
+ async updateTask(actor, input) {
116
+ const row = await deps.boards.taskRow(actor, input.taskId, "write");
117
+ if (row === null) {
118
+ throw new IntelError(404, "task_not_found", "No task with this id is readable for you.");
119
+ }
120
+ if (input.status !== undefined) {
121
+ requireColumn(await columnsOrRefuse(actor, row.boardId, "write"), input.status);
122
+ }
123
+ // Only what arrived. An absent field means "leave it"; `null` where the column is nullable
124
+ // means "clear it", which is why the check is against `undefined` and not against falsiness —
125
+ // `dueDate: null` and `position: 0` both have to get through.
126
+ const fields = {};
127
+ if (input.title !== undefined) {
128
+ // ⚠️ The title lives on the NODE, not here. `board_tasks` has no title column, and adding
129
+ // one would make two rows disagree about what a card is called.
130
+ throw new IntelError(400, "title_belongs_to_the_node", "Rename a task with `node_update`; the board row carries no title.");
131
+ }
132
+ if (input.status !== undefined)
133
+ fields.status = input.status;
134
+ if (input.position !== undefined)
135
+ fields.position = input.position;
136
+ if (input.assigneeId !== undefined)
137
+ fields.assignee_id = input.assigneeId;
138
+ if (input.labels !== undefined)
139
+ fields.labels_json = JSON.stringify(input.labels);
140
+ if (input.startDate !== undefined)
141
+ fields.start_date = input.startDate;
142
+ if (input.dueDate !== undefined)
143
+ fields.due_date = input.dueDate;
144
+ if (input.dependsOn !== undefined)
145
+ fields.depends_on = input.dependsOn;
146
+ /**
147
+ * A move that names a new column but no position lands at the end of it — otherwise the card
148
+ * would keep the number it had in its old column and appear at an arbitrary place.
149
+ *
150
+ * ⚠️ **Only when the column actually CHANGES.** Asking again for the column a card is already
151
+ * in is what a retry looks like, and recomputing there would move the card one place further
152
+ * on every attempt while every call reported success. That is the idempotency the tool
153
+ * promises with `idempotentHint: true`, kept by construction rather than by a key lookup.
154
+ */
155
+ if (input.status !== undefined &&
156
+ input.position === undefined &&
157
+ row.status !== input.status) {
158
+ fields.position = await deps.boards.nextPosition(row.boardId, input.status);
159
+ }
160
+ if (Object.keys(fields).length > 0) {
161
+ await deps.boards.updateTask(input.taskId, fields, deps.now().toISOString());
162
+ }
163
+ return await viewOrRefuse(actor, { boardId: row.boardId, includeArchived: false });
164
+ },
165
+ };
166
+ }
@@ -0,0 +1,51 @@
1
+ import type { BoardGetInput, BoardTaskCreateInput, BoardTaskUpdateInput, BoardUpdateInput, BoardView } from "@anchrd/intel-contract/board";
2
+ import type { Node } from "@anchrd/intel-contract/node";
3
+ import type { Actor } from "../nodes/nodes.types.js";
4
+ export type { BoardView };
5
+ export interface BoardTaskRow {
6
+ nodeId: string;
7
+ boardId: string;
8
+ status: string;
9
+ position: number;
10
+ }
11
+ export interface BoardTaskWrite {
12
+ nodeId: string;
13
+ boardId: string;
14
+ status: string;
15
+ assigneeId: string | null;
16
+ labels: string[];
17
+ startDate: string | null;
18
+ dueDate: string | null;
19
+ dependsOn: string | null;
20
+ position: number;
21
+ occurredAt: string;
22
+ }
23
+ export interface BoardRepository {
24
+ read(actor: Actor, input: BoardGetInput): Promise<BoardView | null>;
25
+ columnsOf(actor: Actor, boardId: string, verb: "read" | "write"): Promise<string[] | null>;
26
+ setColumns(boardId: string, columnsJson: string, occurredAt: string): Promise<void>;
27
+ attach(write: BoardTaskWrite): Promise<void>;
28
+ taskRow(actor: Actor, taskId: string, verb: "read" | "write"): Promise<BoardTaskRow | null>;
29
+ updateTask(taskId: string, fields: Record<string, string | number | null>, occurredAt: string): Promise<void>;
30
+ nextPosition(boardId: string, status: string): Promise<number>;
31
+ }
32
+ export interface BoardNodePort {
33
+ create(actor: Actor, input: {
34
+ parentId: string;
35
+ kind: "task";
36
+ title: string;
37
+ description: null;
38
+ idempotencyKey: string;
39
+ }): Promise<Node>;
40
+ }
41
+ export interface BoardDeps {
42
+ boards: BoardRepository;
43
+ nodes: BoardNodePort;
44
+ now(): Date;
45
+ }
46
+ export interface BoardService {
47
+ get(actor: Actor, input: BoardGetInput): Promise<BoardView>;
48
+ update(actor: Actor, input: BoardUpdateInput): Promise<BoardView>;
49
+ createTask(actor: Actor, input: BoardTaskCreateInput): Promise<BoardView>;
50
+ updateTask(actor: Actor, input: BoardTaskUpdateInput): Promise<BoardView>;
51
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/http/http.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { AuditListRequest } from "@anchrd/intel-contract/audit";
2
+ import { BoardGetInput, BoardTaskCreateInput, BoardTaskUpdateInput, BoardUpdateInput, } from "@anchrd/intel-contract/board";
1
3
  import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
2
4
  import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
3
5
  import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
@@ -204,6 +206,71 @@ export function createHttp(deps) {
204
206
  const input = GetNodeInput.parse({ nodeId: context.req.param("nodeId") });
205
207
  return zipResponse(await deps.bundle.exportSubtree(asBundleActor(auth), input.nodeId));
206
208
  });
209
+ // The change journal, read forward from a stable position (#620). `nodes/read` and nothing new:
210
+ // an audit row about a node says the same thing the node says, so a second capability would be a
211
+ // second answer to one question. The per-row check is the service's, and it is the same tree walk
212
+ // the node reads go through.
213
+ //
214
+ // ⚠️ GET with the cursor in the query string, because the verb is one of the four in the grammar
215
+ // and the method carries it. `limit` arrives as a string and is coerced here — the contract
216
+ // wants a number, and a query string has none.
217
+ app.get("/audit", async (context) => {
218
+ const auth = requireCapability(context, "nodes", "read");
219
+ const limit = context.req.query("limit");
220
+ const after = context.req.query("after");
221
+ const input = AuditListRequest.parse({
222
+ resourceType: context.req.query("resourceType"),
223
+ ...(after === undefined ? {} : { after }),
224
+ ...(limit === undefined ? {} : { limit: Number(limit) }),
225
+ });
226
+ return context.json(await deps.audit.list(asActor(auth), input));
227
+ });
228
+ // The whole board in one answer (#648). `nodes/read` and nothing new: a board is a node, and a
229
+ // card is a node — a second capability would be a second answer to one question.
230
+ //
231
+ // ⚠️ Every filter is a query parameter and every one of them reaches the WHERE. Filtering after
232
+ // the read would have carried other people's cards over the wire first.
233
+ app.get("/boards/:boardId", async (context) => {
234
+ const auth = requireCapability(context, "nodes", "read");
235
+ const query = context.req.query();
236
+ const input = BoardGetInput.parse({
237
+ boardId: context.req.param("boardId"),
238
+ ...(query.status === undefined ? {} : { status: query.status }),
239
+ ...(query.assigneeId === undefined ? {} : { assigneeId: query.assigneeId }),
240
+ ...(query.dueBefore === undefined ? {} : { dueBefore: query.dueBefore }),
241
+ ...(query.dependsOn === undefined ? {} : { dependsOn: query.dependsOn }),
242
+ ...(query.includeArchived === undefined
243
+ ? {}
244
+ : { includeArchived: query.includeArchived === "true" }),
245
+ });
246
+ return context.json(await deps.boards.get(asActor(auth), input));
247
+ });
248
+ app.patch("/boards/:boardId", async (context) => {
249
+ const auth = requireCapability(context, "nodes", "write");
250
+ const input = BoardUpdateInput.parse(await context.req.json().catch(() => null));
251
+ if (input.boardId !== context.req.param("boardId")) {
252
+ throw new IntelError(400, "board_id_mismatch", "Path and body board IDs differ");
253
+ }
254
+ return context.json(await deps.boards.update(asActor(auth), input));
255
+ });
256
+ app.post("/boards/:boardId/tasks", async (context) => {
257
+ const auth = requireCapability(context, "nodes", "create");
258
+ const input = BoardTaskCreateInput.parse(await context.req.json().catch(() => null));
259
+ if (input.boardId !== context.req.param("boardId")) {
260
+ throw new IntelError(400, "board_id_mismatch", "Path and body board IDs differ");
261
+ }
262
+ return context.json(await deps.boards.createTask(asActor(auth), input), 201);
263
+ });
264
+ // ⚠️ `PATCH` on the task, not `POST …/move`. Moving a card IS an update of `status` and
265
+ // `position`; a second address for it would be a second way to write one row.
266
+ app.patch("/boards/:boardId/tasks/:taskId", async (context) => {
267
+ const auth = requireCapability(context, "nodes", "write");
268
+ const input = BoardTaskUpdateInput.parse(await context.req.json().catch(() => null));
269
+ if (input.taskId !== context.req.param("taskId")) {
270
+ throw new IntelError(400, "task_id_mismatch", "Path and body task IDs differ");
271
+ }
272
+ return context.json(await deps.boards.updateTask(asActor(auth), input));
273
+ });
207
274
  app.get("/nodes/:nodeId/versions", async (context) => {
208
275
  const auth = requireCapability(context, "nodes", "read");
209
276
  return context.json(await deps.nodes.listVersions(asActor(auth), context.req.param("nodeId")));
@@ -1,5 +1,7 @@
1
1
  import type { GateClient } from "@anchrd/gate-sdk";
2
+ import type { AuditService } from "../audit/audit.types.js";
2
3
  import type { BrowserAuth } from "../auth/auth.types.js";
4
+ import type { BoardService } from "../boards/boards.types.js";
3
5
  import type { BundleService } from "../bundle/bundle.types.js";
4
6
  import type { FlowService } from "../flows/flows.types.js";
5
7
  import type { NodeService } from "../nodes/nodes.types.js";
@@ -10,6 +12,8 @@ export interface HttpDeps {
10
12
  flows: FlowService;
11
13
  tools: ToolService;
12
14
  bundle: BundleService;
15
+ audit: AuditService;
16
+ boards: BoardService;
13
17
  resource: string;
14
18
  resourceMetadataUrl: string;
15
19
  auth?: Pick<BrowserAuth, "resolve">;
@@ -134,6 +134,8 @@ export function createIntel(deps) {
134
134
  flows: deps.flows,
135
135
  tools: deps.tools,
136
136
  bundle: deps.bundle,
137
+ audit: deps.audit,
138
+ boards: deps.boards,
137
139
  resource,
138
140
  resourceMetadataUrl,
139
141
  ...(deps.auth ? { auth: deps.auth } : {}),
@@ -181,6 +183,8 @@ export function createIntel(deps) {
181
183
  nodes: deps.nodes,
182
184
  tools: deps.tools,
183
185
  bundle: deps.bundle,
186
+ audit: deps.audit,
187
+ boards: deps.boards,
184
188
  });
185
189
  });
186
190
  return app;
@@ -1,5 +1,7 @@
1
1
  import type { GateClient } from "@anchrd/gate-sdk";
2
+ import type { AuditService } from "../audit/audit.types.js";
2
3
  import type { BrowserAuth } from "../auth/auth.types.js";
4
+ import type { BoardService } from "../boards/boards.types.js";
3
5
  import type { BundleService } from "../bundle/bundle.types.js";
4
6
  import type { FlowService } from "../flows/flows.types.js";
5
7
  import type { NodeService } from "../nodes/nodes.types.js";
@@ -12,5 +14,7 @@ export interface IntelDeps {
12
14
  flows: FlowService;
13
15
  tools: ToolService;
14
16
  bundle: BundleService;
17
+ audit: AuditService;
18
+ boards: BoardService;
15
19
  auth?: BrowserAuth;
16
20
  }
package/dist/mcp/mcp.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { IdempotencyKey, IntelId } from "@anchrd/intel-contract";
2
+ import { AuditListRequest } from "@anchrd/intel-contract/audit";
3
+ import { BoardGetInput, BoardTaskCreateInput, BoardTaskUpdateInput, BoardUpdateInput, } from "@anchrd/intel-contract/board";
2
4
  import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
3
5
  import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
4
6
  import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
@@ -156,6 +158,76 @@ export async function handleMcp(request, deps) {
156
158
  name: deps.authorization.identity.name ?? null,
157
159
  }));
158
160
  if (permits(deps.authorization, "nodes", "read")) {
161
+ // The change journal, read forward from a stable position (#620). It is what makes Intel the
162
+ // simplest of all signal sources: a consumer keeps its own cursor and asks what happened since,
163
+ // and Intel never learns that the consumer exists. That is the D24 test passed rather than
164
+ // argued — remove every reader and nothing piles up here.
165
+ //
166
+ // ⚠️ `nodes/read`, not a capability of its own. An audit row about a node says the same thing
167
+ // the node says; a second capability would be a second answer to one question, and the two
168
+ // would drift.
169
+ // The board as one answer (#648). Four tools, not five: a move is an update of `status` and
170
+ // `position`, and deleting a task is `node_archive` — nothing here removes a `nodes` row.
171
+ server.registerTool("board_get", {
172
+ title: "Read a board",
173
+ description: "Read one board in a single answer: its columns and every card you may see, without the card bodies. Filter by column, assignee, due date or what a card waits for — each filter narrows the query itself. Open a card's text with node_version_get.",
174
+ inputSchema: BoardGetInput,
175
+ annotations: {
176
+ title: "Read a board",
177
+ readOnlyHint: true,
178
+ destructiveHint: false,
179
+ idempotentHint: true,
180
+ openWorldHint: false,
181
+ },
182
+ }, async (input) => text(await deps.boards.get(actor, input)));
183
+ server.registerTool("board_task_create", {
184
+ title: "Add a card to a board",
185
+ description: "File a new card on a board. Omit the status to put it in the first column. The card is a node like any other — it has its own address, its own permissions and its own history.",
186
+ inputSchema: BoardTaskCreateInput,
187
+ annotations: {
188
+ title: "Add a card to a board",
189
+ readOnlyHint: false,
190
+ destructiveHint: false,
191
+ idempotentHint: false,
192
+ openWorldHint: false,
193
+ },
194
+ }, async (input) => text(await deps.boards.createTask(actor, input)));
195
+ server.registerTool("board_task_update", {
196
+ title: "Change a card",
197
+ description: "Change a card's column, order, assignee, labels or dates. Moving a card is this call with a new status — send a position to place it, or leave it out to put it at the end of the column. An absent field is left alone; null clears one. Rename a card with node_update.",
198
+ inputSchema: BoardTaskUpdateInput,
199
+ annotations: {
200
+ title: "Change a card",
201
+ readOnlyHint: false,
202
+ destructiveHint: false,
203
+ idempotentHint: true,
204
+ openWorldHint: false,
205
+ },
206
+ }, async (input) => text(await deps.boards.updateTask(actor, input)));
207
+ server.registerTool("board_update", {
208
+ title: "Set a board's columns",
209
+ description: "Replace a board's column list, in order. Columns are replaced rather than merged, so send the complete list. A column marked terminal is one where a card counts as finished.",
210
+ inputSchema: BoardUpdateInput,
211
+ annotations: {
212
+ title: "Set a board's columns",
213
+ readOnlyHint: false,
214
+ destructiveHint: false,
215
+ idempotentHint: true,
216
+ openWorldHint: false,
217
+ },
218
+ }, async (input) => text(await deps.boards.update(actor, input)));
219
+ server.registerTool("audit_list", {
220
+ title: "List change events",
221
+ description: "Read the change journal forward from a stable position: what happened to the nodes you may read, oldest first. Pass `nextCursor` back as `after` to continue where you stopped; a null `nextCursor` means you have caught up. Only node events can be listed — flow events need their own visibility check and are refused rather than left out. Events about permanently deleted nodes are never returned, to anybody.",
222
+ inputSchema: AuditListRequest,
223
+ annotations: {
224
+ title: "List change events",
225
+ readOnlyHint: true,
226
+ destructiveHint: false,
227
+ idempotentHint: true,
228
+ openWorldHint: false,
229
+ },
230
+ }, async (input) => text(await deps.audit.list(actor, input)));
159
231
  server.registerTool("node_list", {
160
232
  title: "List nodes",
161
233
  description: "List authorized folders, documents, attachments, and tables under one parent.",
@@ -1,4 +1,6 @@
1
1
  import type { Authorized } from "@anchrd/gate-sdk";
2
+ import type { AuditService } from "../audit/audit.types.js";
3
+ import type { BoardService } from "../boards/boards.types.js";
2
4
  import type { BundleService } from "../bundle/bundle.types.js";
3
5
  import type { FlowService } from "../flows/flows.types.js";
4
6
  import type { NodeService } from "../nodes/nodes.types.js";
@@ -14,4 +16,6 @@ export interface McpDeps {
14
16
  flows: FlowService;
15
17
  tools: ToolService;
16
18
  bundle: BundleService;
19
+ audit: AuditService;
20
+ boards: BoardService;
17
21
  }
@@ -62,6 +62,55 @@ function decodeBase64(value) {
62
62
  throw new IntelError(400, "attachment_invalid", "Attachment content is not valid base64");
63
63
  }
64
64
  }
65
+ /**
66
+ * Where a kind may be filed, and it is one rule read by two callers — `create` and the move inside
67
+ * `update` (#377).
68
+ *
69
+ * ⚠️ **Three cases, and the first version had only one of them.** It handled "wrong parent that
70
+ * exists" and left two doors open, both of which produced exactly the state the rule exists against:
71
+ *
72
+ * * **No parent at all.** `parentId: null` is the ROOT, and the root is not a board. The first
73
+ * version skipped the check entirely for `null`, so `node_create({ kind: "task", parentId:
74
+ * null })` made a task with no board — and `attachToBoard` did not run either, for the same
75
+ * reason. A task on no board, created through the front door.
76
+ * * **Right kind, wrong board.** Every `kind === "board"` was accepted as a destination, so a
77
+ * task could be moved to ANOTHER board while its `board_tasks.board_id` stayed behind. That
78
+ * divergence was reachable only by writing D1 by hand before this rule existed (the join in
79
+ * `db-boards.ts` guards against it); the first version of this rule opened a real code path to
80
+ * it. **A change that widens what is allowed has to be read for what it now permits, not only
81
+ * for what it now refuses.**
82
+ *
83
+ * ⚠️ The refusal names the KIND in the sentence rather than a bare "not a folder". Somebody
84
+ * dragging a card into a folder has to learn what went wrong, not that something did.
85
+ */
86
+ function refuseWrongParent(childKind, parent, currentParentId) {
87
+ if (childKind === "task") {
88
+ if (parent === null) {
89
+ throw new IntelError(409, "task_belongs_to_a_board", "A task lives on a board. The top level is not one, so there is nowhere for it to sit there.");
90
+ }
91
+ if (parent.kind !== "board") {
92
+ throw new IntelError(409, "task_belongs_to_a_board", `A task lives on a board and cannot be filed under a ${parent.kind}. Move the board instead, or make a new task on the board you want it on.`);
93
+ }
94
+ // ⚠️ The same board, not merely a board. `board_tasks.board_id` does not travel with a move
95
+ // through the node path, so a card handed to another board would answer from one board's query
96
+ // while its row names the other — and the card would show up on neither, or on both.
97
+ if (currentParentId !== undefined &&
98
+ currentParentId !== null &&
99
+ parent.id !== currentParentId) {
100
+ throw new IntelError(409, "task_cannot_change_board", "A card cannot be handed to another board this way. Make it on the board you want it on; its position, status and dependencies belong to the board it was filed on.");
101
+ }
102
+ return;
103
+ }
104
+ // The root takes everything else — that is what a top-level folder or document is.
105
+ if (parent === null)
106
+ return;
107
+ if (parent.kind === "board") {
108
+ throw new IntelError(409, "board_holds_only_tasks", `A board holds tasks and nothing else; a ${childKind} cannot be filed on one.`);
109
+ }
110
+ if (parent.kind !== "folder") {
111
+ throw new IntelError(409, "parent_not_folder", "A node's parent must be a folder");
112
+ }
113
+ }
65
114
  export function createNodes(deps) {
66
115
  function mergeSearchResults(lexical, semantic, semanticScores, limit) {
67
116
  const merged = new Map();
@@ -446,13 +495,36 @@ export function createNodes(deps) {
446
495
  },
447
496
  async create(actor, input) {
448
497
  const existingId = await deps.repository.findIdempotentNode(actor.id, "node.create", input.idempotencyKey);
449
- if (existingId)
450
- return await requireVisible(actor, existingId);
498
+ if (existingId) {
499
+ const existing = await requireVisible(actor, existingId);
500
+ /**
501
+ * ⚠️ **The filing runs on the REPLAY path too, and that is what makes a retry a repair.**
502
+ *
503
+ * `insertNode` commits the node, its idempotency key and its audit row in one batch;
504
+ * `attachToBoard` is a separate statement after it. If that one fails — a transient D1
505
+ * error is enough — the node exists and its board row does not, and `create` throws. The
506
+ * obvious response is the same call again with the same key, and until this line that call
507
+ * returned here **without filing anything**: a task that is a task by kind, sits under a
508
+ * board, and appears on no board, permanently, with nothing reporting it.
509
+ *
510
+ * `attach` is `ON CONFLICT DO NOTHING`, so running it on a node that is already filed costs
511
+ * one statement and changes nothing. That asymmetry is why the repair belongs here rather
512
+ * than in a sweeper somebody has to remember to write.
513
+ */
514
+ if (existing.kind === "task" && existing.parentId !== null) {
515
+ await deps.attachToBoard?.(actor, existing.id, existing.parentId, deps.now().toISOString());
516
+ }
517
+ return existing;
518
+ }
451
519
  if (input.parentId !== null && !(await deps.repository.can(actor, input.parentId, "write"))) {
452
520
  throw new IntelError(403, "node_forbidden", "Parent folder cannot be edited");
453
521
  }
522
+ // ⚠️ The same rule on the way IN, and it runs for the ROOT as well. Guarding only a named
523
+ // parent leaves `parentId: null` open, and that is not a smaller hole: the task is created,
524
+ // `attachToBoard` skips it for the same reason, and the card belongs to no board at all.
525
+ refuseWrongParent(input.kind, input.parentId === null ? null : await requireVisible(actor, input.parentId));
454
526
  const timestamp = deps.now().toISOString();
455
- return await deps.repository.insertNode({
527
+ const created = await deps.repository.insertNode({
456
528
  node: {
457
529
  id: deps.id(),
458
530
  parentId: input.parentId,
@@ -469,6 +541,25 @@ export function createNodes(deps) {
469
541
  idempotencyKey: input.idempotencyKey,
470
542
  auditId: deps.id(),
471
543
  });
544
+ /**
545
+ * ⚠️ **A task created through the plain node path still gets its board row** (#648). Without
546
+ * this an agent calling `node_create` under a board makes a node that is a task by kind, has
547
+ * no row in `board_tasks`, and therefore appears on NO board — with nothing reporting an
548
+ * error. It is exactly the shape of failure this repository keeps writing rules about: not a
549
+ * refusal, an absence.
550
+ *
551
+ * ⚠️ It runs AFTER the node exists and is its own statement rather than part of the batch.
552
+ * The node is the truth; the board row is derived from it. A pass that dies in between leaves
553
+ * a task nobody filed — visible through `node_get`, repairable by filing it — while the other
554
+ * order would leave a board row pointing at a node that was never written.
555
+ *
556
+ * The port is optional because the node service must not require a board to exist: the CLI
557
+ * and the bundle importer build one without that half of the world.
558
+ */
559
+ if (created.kind === "task" && input.parentId !== null) {
560
+ await deps.attachToBoard?.(actor, created.id, input.parentId, timestamp);
561
+ }
562
+ return created;
472
563
  },
473
564
  async save(actor, input) {
474
565
  const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
@@ -779,15 +870,16 @@ export function createNodes(deps) {
779
870
  const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.update", input.idempotencyKey);
780
871
  if (replayedId)
781
872
  return await requireVisible(actor, replayedId);
782
- if (input.parentId !== undefined && input.parentId !== null) {
873
+ // ⚠️ `undefined` means "do not move" and is the only value that skips this. An explicit
874
+ // `null` IS a move — to the root — and has to be checked like any other destination; the
875
+ // first version of this treated the two the same and let a card be dragged to the top level.
876
+ if (input.parentId !== undefined) {
783
877
  if (input.parentId === current.id) {
784
878
  throw new IntelError(409, "move_cycle", "A node cannot contain itself");
785
879
  }
786
- const parent = await requireVisible(actor, input.parentId);
787
- if (parent.kind !== "folder") {
788
- throw new IntelError(409, "parent_not_folder", "A node's parent must be a folder");
789
- }
790
- if (!(await deps.repository.can(actor, parent.id, "write"))) {
880
+ const parent = input.parentId === null ? null : await requireVisible(actor, input.parentId);
881
+ refuseWrongParent(current.kind, parent, current.parentId);
882
+ if (parent !== null && !(await deps.repository.can(actor, parent.id, "write"))) {
791
883
  throw new IntelError(403, "node_forbidden", "Destination folder cannot be edited");
792
884
  }
793
885
  }
@@ -213,6 +213,16 @@ export interface NodeAttachmentBody {
213
213
  }
214
214
  export interface NodesDeps {
215
215
  repository: NodeRepository;
216
+ /**
217
+ * Files a freshly created `task` node on the board it was created under (#648).
218
+ *
219
+ * ⚠️ Optional on purpose, and the optionality is the decision rather than a convenience: the node
220
+ * service is the one path every node takes, including in the CLI and the bundle importer, and
221
+ * neither of those has a board half of the world. A required port would make "create a node" fail
222
+ * where boards are not wired — and the answer to a missing board is that a task keeps its node
223
+ * and gets filed later, never that the node is refused.
224
+ */
225
+ attachToBoard?(actor: Actor, taskId: string, boardId: string, occurredAt: string): Promise<void>;
216
226
  content: ContentStore;
217
227
  id(): string;
218
228
  now(): Date;
@@ -0,0 +1,14 @@
1
+ -- A change feed reads the journal in the order it happened, not by resource. The two indexes from
2
+ -- 0000 both lead with a resource or an actor, so neither can serve "everything since position X":
3
+ -- SQLite would have to scan and sort the whole table on every poll.
4
+ --
5
+ -- ⚠️ Both columns, in the order the cursor compares them. `occurred_at` alone is not enough and the
6
+ -- shortfall is invisible: two events written in the same millisecond — the normal case inside one
7
+ -- batch, not the exception — are indistinguishable, and a reader continuing past the timestamp
8
+ -- skips the second one with no error and no log. The tie-break on `id` is what makes the position
9
+ -- stable, and the index has to carry it or the sort falls back to a scan (anchrd/intel#620).
10
+ --
11
+ -- `resource_type` leads because every query names exactly one kind: today only node events can be
12
+ -- listed at all, and flow events need a different visibility check before they could be.
13
+ CREATE INDEX audit_events_feed_idx
14
+ ON audit_events(resource_type, occurred_at, id);