@anchrd/intel-api 0.27.0 → 0.29.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,288 @@
1
+ import { ARCHIVE_COLUMN_ID } from "@anchrd/intel-contract/board";
2
+ import { IntelError } from "../shared/intel-error/intel-error.js";
3
+ /**
4
+ * What a board with no configuration answers with. A board is usable the moment it is created, and
5
+ * nothing asks whoever made it to define columns before filing the first card.
6
+ *
7
+ * ⚠️ **This is the FALLBACK, not what a board made through the application gets.** Columns are
8
+ * shared data: two people on one board must read the same names, and the settings dialog edits one
9
+ * list. So the application writes them at creation, in the language of whoever created it (#675),
10
+ * and from then on they are data anybody can rename. This list only answers for a board nobody
11
+ * configured — one made over MCP, or one whose seeding did not land.
12
+ */
13
+ export const DEFAULT_COLUMNS = [
14
+ { id: "backlog", title: "Backlog", terminal: false },
15
+ { id: "todo", title: "Offen", terminal: false },
16
+ { id: "doing", title: "Läuft", terminal: false },
17
+ { id: "done", title: "Fertig", terminal: true },
18
+ ];
19
+ // The column a card lands in when nobody said and the board has none configured. One export rather
20
+ // than the literal `"todo"` in three places: the fallback exists FOR the unconfigured board, so a
21
+ // copy of it would drift exactly where it is the only thing deciding.
22
+ export const FIRST_DEFAULT_COLUMN = DEFAULT_COLUMNS[0].id;
23
+ export function createBoards(deps) {
24
+ /**
25
+ * ⚠️ The one place "which board, and may this actor DO THIS to it" is answered — and the verb is
26
+ * the whole question. It asked `read` for every caller once, including the three that were about
27
+ * to write: a board shared read-only could have its columns replaced and its cards moved. The
28
+ * refusal existed and asked the wrong thing, which is the harder failure to see.
29
+ *
30
+ * A `404` rather than a `403`, on purpose: "this board exists but you may not touch it" is itself
31
+ * information about somebody else's tree.
32
+ */
33
+ const columnsOrRefuse = async (actor, boardId, verb) => {
34
+ const columns = await deps.boards.columnsOf(actor, boardId, verb);
35
+ if (columns === null) {
36
+ throw new IntelError(404, "board_not_found", "No board with this id is readable for you.");
37
+ }
38
+ return columns.length > 0 ? columns : DEFAULT_COLUMNS.map((column) => column.id);
39
+ };
40
+ const viewOrRefuse = async (actor, input) => {
41
+ const view = await deps.boards.read(actor, input);
42
+ if (view === null) {
43
+ throw new IntelError(404, "board_not_found", "No board with this id is readable for you.");
44
+ }
45
+ // A board that was never configured answers with the default columns rather than none. An empty
46
+ // column list would render as a board with no columns at all — a screen with nowhere to put a
47
+ // card, which looks broken rather than new.
48
+ /**
49
+ * ⚠️ **A stored column may not claim the archive id, and the migration is not the only guard.**
50
+ * `0024` renames the ones that existed before the id was reserved, and `update` refuses new
51
+ * ones — but a row written straight into D1, or a migration that did not reach an installation,
52
+ * would still put two columns with one id in front of a reader. Dropping it here means the
53
+ * derived one always wins, which is the answer that cannot produce two.
54
+ */
55
+ const configured = (view.columns.length > 0 ? view.columns : [...DEFAULT_COLUMNS]).filter((column) => column.id !== ARCHIVE_COLUMN_ID);
56
+ /**
57
+ * ⚠️ **The archive column is appended here, after the fallback, and it is never stored.** A
58
+ * card is in it because `nodes.archived_at` is set; `board_tasks.status` keeps naming the
59
+ * working column underneath, which is what a card pulled back out returns to (D68).
60
+ *
61
+ * Always last, and not part of the list `board_update` writes: the archive is the same place on
62
+ * every board. A shelf that could be dragged between the working columns would be a shelf
63
+ * pretending to be a stage.
64
+ *
65
+ * ⚠️ **The title here is a fallback, not the label a reader sees.** Configured columns are
66
+ * shared data and are therefore written once in the creator's language (#675); this one is
67
+ * derived and identical on every board, so the browser says it in the reader's own words
68
+ * (`useBoard`). What travels is English, for everything without a catalog — MCP, an export, a
69
+ * report.
70
+ */
71
+ return {
72
+ ...view,
73
+ columns: view.archiveVisible
74
+ ? [...configured, { id: ARCHIVE_COLUMN_ID, title: "Archive", terminal: true }]
75
+ : configured,
76
+ };
77
+ };
78
+ const requireColumn = (columns, status) => {
79
+ if (columns.includes(status))
80
+ return;
81
+ throw new IntelError(400, "unknown_column", `This board has no column \`${status}\`. Its columns are: ${columns.join(", ")}.`);
82
+ };
83
+ return {
84
+ async get(actor, input) {
85
+ return await viewOrRefuse(actor, input);
86
+ },
87
+ async update(actor, input) {
88
+ await columnsOrRefuse(actor, input.boardId, "write");
89
+ // ⚠️ Duplicate ids are refused rather than deduplicated. Two columns with one id means every
90
+ // card in them lands in whichever the view draws first, and silently dropping one would
91
+ // discard a column somebody just wrote.
92
+ const ids = input.columns.map((column) => column.id);
93
+ const duplicate = ids.find((id, index) => ids.indexOf(id) !== index);
94
+ if (duplicate !== undefined) {
95
+ throw new IntelError(400, "duplicate_column", `The column id \`${duplicate}\` appears more than once.`);
96
+ }
97
+ /**
98
+ * ⚠️ **The archive id cannot be claimed by a configured column.** It is derived — a card is in
99
+ * it because `archived_at` is set — so a stored column of the same id would give a board two
100
+ * columns with one id: every card in them lands in whichever the view draws first, and one of
101
+ * the two can never receive a card at all.
102
+ */
103
+ /**
104
+ * ⚠️ **Duplicate NAMES are refused here, not only in the dialog.** An id nobody sees may
105
+ * repeat without confusing anybody; a name is what a person reads, and two columns called the
106
+ * same thing cannot be told apart on the board, in the filter, or in the settings. Checked in
107
+ * the service because an agent calling `board_update` reaches no dialog — root `CLAUDE.md`:
108
+ * validation does not vary by surface.
109
+ */
110
+ const titles = input.columns.map((column) => column.title.trim());
111
+ const duplicateTitle = titles.find((title, index) => titles.indexOf(title) !== index);
112
+ if (duplicateTitle !== undefined) {
113
+ throw new IntelError(400, "duplicate_column_name", `Two columns are called \`${duplicateTitle}\`. A name is what tells them apart.`);
114
+ }
115
+ if (ids.includes(ARCHIVE_COLUMN_ID)) {
116
+ throw new IntelError(400, "reserved_column", `\`${ARCHIVE_COLUMN_ID}\` is the archive column, which every board has. Show or hide it instead of configuring it.`);
117
+ }
118
+ await deps.boards.setColumns(input.boardId, JSON.stringify(input.columns), deps.now().toISOString(), input.archiveVisible);
119
+ return await viewOrRefuse(actor, { boardId: input.boardId, includeArchived: false });
120
+ },
121
+ async createTask(actor, input) {
122
+ const columns = await columnsOrRefuse(actor, input.boardId, "write");
123
+ // Absent means the first column — a caller who does not care where a task starts should not
124
+ // have to read the board first. A named one is checked, because a typo would otherwise create
125
+ // a card in a column no view draws.
126
+ const status = input.status ?? columns[0] ?? DEFAULT_COLUMNS[0].id;
127
+ if (input.status !== undefined)
128
+ requireColumn(columns, status);
129
+ /**
130
+ * ⚠️ **The parent's board must BE the board that was asked for**, checked here rather than
131
+ * left to the node path. `nodes.create` resolves the destination board from the parent, and
132
+ * on creation there is no previous board to compare it with — so a call naming board B with a
133
+ * parent on board A files the card on **A**, answers with the view of **B**, and the caller
134
+ * gets neither an error nor their card. The position would have been counted against B too.
135
+ */
136
+ if (input.parentTaskId !== null) {
137
+ // ⚠️ `"write"` for the same reason `attachToBoard` uses it — filing a card here is a write
138
+ // to that board. It is defence in depth rather than the only guard: `nodes.create` checks
139
+ // `write` on the parent node itself and answers 403 first, which is why a behaviour test
140
+ // cannot tell `"read"` from `"write"` here. Named so the next reader does not go looking
141
+ // for the test that proves it.
142
+ const parentRow = await deps.boards.taskRow(actor, input.parentTaskId, "write");
143
+ /**
144
+ * ⚠️ **Only the wrong BOARD is refused here.** `taskRow` answers `null` for three different
145
+ * things — the node does not exist, it is not a task, or this actor may not write it — and
146
+ * each of those already has a precise answer further in: `404 node_not_found` from
147
+ * `requireVisible`, `task_belongs_to_a_board` from `refuseWrongParent`, `403` from the
148
+ * write check. Catching them here would answer "that parent card is not on this board" for
149
+ * a card that does not exist, which claims it does.
150
+ */
151
+ if (parentRow !== null && parentRow.boardId !== input.boardId) {
152
+ throw new IntelError(409, "task_cannot_change_board", "That parent card is not on this board. A subtask belongs to the same board as the card it hangs under.");
153
+ }
154
+ }
155
+ // ⚠️ Through the node service, not a second insert. The task gets the same authorization
156
+ // check, the same audit row and the same version chain every other node gets — and that audit
157
+ // row is what `anchrd/signals` reads (#620).
158
+ const node = await deps.nodes.create(actor, {
159
+ // ⚠️ A subtask's parent is the TASK, not the board — the hierarchy is the node tree (#669).
160
+ // The node service resolves which board that lands on and files the row there, so nothing
161
+ // here has to know: naming the board as parent would flatten every subtask on creation.
162
+ parentId: input.parentTaskId ?? input.boardId,
163
+ kind: "task",
164
+ title: input.title,
165
+ description: null,
166
+ idempotencyKey: input.idempotencyKey,
167
+ });
168
+ /**
169
+ * ⚠️ **`update`, not `attach` — the row already exists by the time this line runs**, and
170
+ * getting that wrong is silent. `nodes.create` files every new task through `attachToBoard`,
171
+ * with the board's first column and no fields; a second `INSERT` here is refused by the
172
+ * primary key, `ON CONFLICT DO NOTHING` swallows the refusal, and the caller's `status`,
173
+ * assignee and dates are **discarded without an error**. The card lands in the wrong column
174
+ * and nothing anywhere says why.
175
+ *
176
+ * It was written as an insert first, and three tests caught it: a card created in `doing`
177
+ * came back in `todo`. The lesson is the shape rather than the line — one row, one writer,
178
+ * and the writer is the node path.
179
+ */
180
+ /**
181
+ * ⚠️ **The position is computed only when the card is not already in that column**, and that
182
+ * is what makes a retry safe. `deps.nodes.create` honours the idempotency key and answers with
183
+ * the SAME node on a replay; recomputing here would push the card one place further on every
184
+ * attempt — a client retrying a timeout would watch its card walk down the column, with every
185
+ * call succeeding.
186
+ *
187
+ * The other fields are rewritten with the same values, which is idempotent by construction.
188
+ */
189
+ const filed = await deps.boards.taskRow(actor, node.id, "write");
190
+ await deps.boards.updateTask(node.id, {
191
+ status,
192
+ assignee_id: input.assigneeId,
193
+ labels_json: JSON.stringify(input.labels),
194
+ start_date: input.startDate,
195
+ due_date: input.dueDate,
196
+ depends_on: input.dependsOn,
197
+ ...(filed?.status === status
198
+ ? {}
199
+ : { position: await deps.boards.nextPosition(input.boardId, status) }),
200
+ }, deps.now().toISOString());
201
+ return await viewOrRefuse(actor, { boardId: input.boardId, includeArchived: false });
202
+ },
203
+ async updateTask(actor, input) {
204
+ const row = await deps.boards.taskRow(actor, input.taskId, "write");
205
+ if (row === null) {
206
+ throw new IntelError(404, "task_not_found", "No task with this id is readable for you.");
207
+ }
208
+ /**
209
+ * ⚠️ **Dropping a card on the archive column archives it; pulling it out restores it** (D68).
210
+ * Neither writes `board_tasks.status`: the working column underneath is what a restored card
211
+ * returns to, and overwriting it with `archived` would be the second stored truth this
212
+ * design exists against.
213
+ *
214
+ * `nodes.archive` is the same door `node_archive` uses — one path, one audit row, one set of
215
+ * refusals (a card with live subtasks is still refused, #669).
216
+ */
217
+ const intoArchive = input.status === ARCHIVE_COLUMN_ID;
218
+ const outOfArchive = input.status !== undefined && !intoArchive && row.archivedAt !== null;
219
+ if (intoArchive || outOfArchive) {
220
+ await deps.nodes.archive(actor, {
221
+ nodeId: input.taskId,
222
+ baseUpdatedAt: row.updatedAt,
223
+ archived: intoArchive,
224
+ idempotencyKey: `${input.idempotencyKey}:archive`,
225
+ });
226
+ }
227
+ if (input.status !== undefined && !intoArchive) {
228
+ requireColumn(await columnsOrRefuse(actor, row.boardId, "write"), input.status);
229
+ }
230
+ // Only what arrived. An absent field means "leave it"; `null` where the column is nullable
231
+ // means "clear it", which is why the check is against `undefined` and not against falsiness —
232
+ // `dueDate: null` and `position: 0` both have to get through.
233
+ const fields = {};
234
+ if (input.title !== undefined) {
235
+ // ⚠️ The title lives on the NODE, not here. `board_tasks` has no title column, and adding
236
+ // one would make two rows disagree about what a card is called.
237
+ throw new IntelError(400, "title_belongs_to_the_node", "Rename a task with `node_update`; the board row carries no title.");
238
+ }
239
+ // ⚠️ Not for a drop into the archive: the working column stays as it is, so the card knows
240
+ // where to go back to.
241
+ if (input.status !== undefined && !intoArchive)
242
+ fields.status = input.status;
243
+ // ⚠️ Not for a drop into the archive either. The kanban sends a position with EVERY drop,
244
+ // computed from the neighbours inside the column it was dropped on — and inside the archive
245
+ // those are cards from all over the board. Taken, it would overwrite the card's place in the
246
+ // working column with a number that means nothing there. The status is guarded one line up;
247
+ // without this line the guard only covered half the write.
248
+ if (input.position !== undefined && !intoArchive)
249
+ fields.position = input.position;
250
+ if (input.assigneeId !== undefined)
251
+ fields.assignee_id = input.assigneeId;
252
+ if (input.labels !== undefined)
253
+ fields.labels_json = JSON.stringify(input.labels);
254
+ if (input.startDate !== undefined)
255
+ fields.start_date = input.startDate;
256
+ if (input.dueDate !== undefined)
257
+ fields.due_date = input.dueDate;
258
+ if (input.dependsOn !== undefined)
259
+ fields.depends_on = input.dependsOn;
260
+ /**
261
+ * A move that names a new column but no position lands at the end of it — otherwise the card
262
+ * would keep the number it had in its old column and appear at an arbitrary place.
263
+ *
264
+ * ⚠️ **Only when the column actually CHANGES.** Asking again for the column a card is already
265
+ * in is what a retry looks like, and recomputing there would move the card one place further
266
+ * on every attempt while every call reported success. That is the idempotency the tool
267
+ * promises with `idempotentHint: true`, kept by construction rather than by a key lookup.
268
+ */
269
+ /**
270
+ * ⚠️ **Never for a drop into the archive**, and the reason is the same one that keeps
271
+ * `fields.status` unset there: `board_tasks.status` never holds `archived`, so
272
+ * `nextPosition(board, "archived")` counts zero rows and answers `1` — every time. The card
273
+ * keeps its working column but silently loses its place IN it, landing first among cards it
274
+ * used to sit behind. Nothing reports that, and the reader only sees it after restoring.
275
+ */
276
+ if (input.status !== undefined &&
277
+ input.position === undefined &&
278
+ row.status !== input.status &&
279
+ !intoArchive) {
280
+ fields.position = await deps.boards.nextPosition(row.boardId, input.status);
281
+ }
282
+ if (Object.keys(fields).length > 0) {
283
+ await deps.boards.updateTask(input.taskId, fields, deps.now().toISOString());
284
+ }
285
+ return await viewOrRefuse(actor, { boardId: row.boardId, includeArchived: false });
286
+ },
287
+ };
288
+ }
@@ -0,0 +1,76 @@
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
+ * From the NODE, not from `board_tasks` (D68, #674).
12
+ *
13
+ * ⚠️ Whether a card is archived is `nodes.archived_at` and nothing else. The row carries it so
14
+ * that a move can tell "into the archive" from "out of it" without a second read — and so that
15
+ * nobody is tempted to store the answer here, which would be the two truths D68 exists against.
16
+ */
17
+ archivedAt: string | null;
18
+ updatedAt: string;
19
+ }
20
+ export interface BoardTaskWrite {
21
+ nodeId: string;
22
+ boardId: string;
23
+ status: string;
24
+ assigneeId: string | null;
25
+ labels: string[];
26
+ startDate: string | null;
27
+ dueDate: string | null;
28
+ dependsOn: string | null;
29
+ position: number;
30
+ occurredAt: string;
31
+ }
32
+ export interface BoardRepository {
33
+ read(actor: Actor, input: BoardGetInput): Promise<BoardView | null>;
34
+ columnsOf(actor: Actor, boardId: string, verb: "read" | "write"): Promise<string[] | null>;
35
+ /**
36
+ * ⚠️ `archiveVisible` is `undefined` for "leave it as it is" — the dialog that writes the columns
37
+ * and the switch that hides the archive are the same call, and a caller who only reorders columns
38
+ * must not silently un-hide an archive somebody put away.
39
+ */
40
+ setColumns(boardId: string, columnsJson: string, occurredAt: string, archiveVisible?: boolean): Promise<void>;
41
+ attach(write: BoardTaskWrite): Promise<void>;
42
+ taskRow(actor: Actor, taskId: string, verb: "read" | "write"): Promise<BoardTaskRow | null>;
43
+ updateTask(taskId: string, fields: Record<string, string | number | null>, occurredAt: string): Promise<void>;
44
+ nextPosition(boardId: string, status: string): Promise<number>;
45
+ }
46
+ export interface BoardNodePort {
47
+ create(actor: Actor, input: {
48
+ parentId: string;
49
+ kind: "task";
50
+ title: string;
51
+ description: null;
52
+ idempotencyKey: string;
53
+ }): Promise<Node>;
54
+ /**
55
+ * ⚠️ The same door `node_archive` uses. Dropping a card on the archive column must not become a
56
+ * second way to archive: one path means one audit row, one set of refusals — a card with live
57
+ * subtasks stays refused (#669) — and one answer to "what does archived mean".
58
+ */
59
+ archive(actor: Actor, input: {
60
+ nodeId: string;
61
+ baseUpdatedAt: string;
62
+ archived: boolean;
63
+ idempotencyKey: string;
64
+ }): Promise<Node>;
65
+ }
66
+ export interface BoardDeps {
67
+ boards: BoardRepository;
68
+ nodes: BoardNodePort;
69
+ now(): Date;
70
+ }
71
+ export interface BoardService {
72
+ get(actor: Actor, input: BoardGetInput): Promise<BoardView>;
73
+ update(actor: Actor, input: BoardUpdateInput): Promise<BoardView>;
74
+ createTask(actor: Actor, input: BoardTaskCreateInput): Promise<BoardView>;
75
+ updateTask(actor: Actor, input: BoardTaskUpdateInput): Promise<BoardView>;
76
+ }
@@ -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. Name a parent task to make it a subtask — it lands on the same board, and moving an existing card under another one is `node_update` with a new parent. The card is a node like any other: its own address, its own permissions, 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. To move a card UNDER another card, use `node_update` with the other card as its parent — this tool changes what a card is, not where it hangs.",
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
  }