@anchrd/intel-api 0.28.0 → 0.30.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.
@@ -123,6 +123,10 @@ export default {
123
123
  // ⚠️ The REPOSITORY, not the board service — that is what keeps this from being a cycle. The
124
124
  // board service needs the node service to create a task; the node service needs only the two
125
125
  // reads below to file one.
126
+ // ⚠️ `"write"` for the same reason as below: this answers "may this actor file a card here",
127
+ // not "may they look at it". Reading it with `"read"` would let somebody nest a card under a
128
+ // task on a board they may only look at.
129
+ boardOfTask: async (who, taskId) => (await boardRepository.taskRow(who, taskId, "write"))?.boardId ?? null,
126
130
  attachToBoard: async (who, taskId, boardId, occurredAt) => {
127
131
  // ⚠️ `"write"` — the actor is filing a task on this board, and the board's own creation
128
132
  // path is a write too. `"read"` here would put back exactly the hole #649 found.
@@ -1,4 +1,4 @@
1
- import { BoardColumn } from "@anchrd/intel-contract/board";
1
+ import { ARCHIVE_COLUMN_ID, BoardColumn } from "@anchrd/intel-contract/board";
2
2
  import { subtreeBindings, subtreeCte } from "./db-grants.js";
3
3
  function parseList(raw, of) {
4
4
  // The column is `NOT NULL DEFAULT '[]'`, but it is JSON written by this repository and read back
@@ -14,16 +14,28 @@ function parseList(raw, of) {
14
14
  return [];
15
15
  }
16
16
  }
17
- function mapTask(row) {
17
+ function mapTask(row, boardId) {
18
18
  return {
19
19
  id: row.id,
20
20
  title: row.title,
21
- status: row.status,
21
+ /**
22
+ * ⚠️ **An archived card reports the ARCHIVE column, not the one it came from** — that is what
23
+ * makes the column real instead of a label. `board_tasks.status` keeps naming the working
24
+ * column underneath, untouched, and that is what a card pulled back out returns to.
25
+ *
26
+ * A card archived over `node_archive`, without anybody dragging it, therefore appears here too.
27
+ * That is the test which tells this design apart from a second stored status (D68).
28
+ */
29
+ status: row.archived_at === null ? row.status : ARCHIVE_COLUMN_ID,
22
30
  assigneeId: row.assignee_id,
23
31
  labels: parseList(row.labels_json, "labels").filter((entry) => typeof entry === "string"),
24
32
  startDate: row.start_date,
25
33
  dueDate: row.due_date,
26
34
  dependsOn: row.depends_on,
35
+ // ⚠️ The board itself is not a parent TASK. A card filed directly on the board has
36
+ // `parent_id = boardId`, and handing that back as `parentTaskId` would make every top-level
37
+ // card claim to be a subtask of the board — a tree one level too deep, in every view.
38
+ parentTaskId: row.parent_id === boardId ? null : row.parent_id,
27
39
  position: row.position,
28
40
  archivedAt: row.archived_at,
29
41
  };
@@ -53,7 +65,8 @@ export function createBoardRepository(deps) {
53
65
  async read(actor, input) {
54
66
  const board = await deps.db
55
67
  .prepare(`${subtreeCte}
56
- SELECT n.id, n.title, COALESCE(b.statuses_json, '[]') AS statuses_json
68
+ SELECT n.id, n.title, COALESCE(b.statuses_json, '[]') AS statuses_json,
69
+ COALESCE(b.archive_visible, 1) AS archive_visible
57
70
  FROM nodes n
58
71
  JOIN allowed ON allowed.id = n.id
59
72
  LEFT JOIN boards b ON b.node_id = n.id
@@ -64,7 +77,14 @@ export function createBoardRepository(deps) {
64
77
  return null;
65
78
  const conditions = ["t.board_id = ?"];
66
79
  const bindings = [input.boardId];
67
- if (input.status !== undefined) {
80
+ if (input.status === ARCHIVE_COLUMN_ID) {
81
+ // ⚠️ The archive column is not a stored status — `board_tasks.status` keeps naming the
82
+ // working column underneath (D68). Matched against `t.status` the filter would answer
83
+ // "nothing here" on a board full of visibly archived cards, and the column would look
84
+ // broken rather than empty.
85
+ conditions.push("n.archived_at IS NOT NULL");
86
+ }
87
+ else if (input.status !== undefined) {
68
88
  conditions.push("t.status = ?");
69
89
  bindings.push(input.status);
70
90
  }
@@ -83,11 +103,18 @@ export function createBoardRepository(deps) {
83
103
  conditions.push("t.depends_on = ?");
84
104
  bindings.push(input.dependsOn);
85
105
  }
86
- if (!input.includeArchived)
106
+ /**
107
+ * ⚠️ **The archive column IS the archived cards** (D68, #674). While it is drawn they belong
108
+ * in the answer, or the column would always be empty and nobody could pull anything back out
109
+ * of it. `includeArchived` stays for the other direction: a caller — an agent, a report —
110
+ * that wants them even on a board where the column is hidden.
111
+ */
112
+ const archiveVisible = board.archive_visible !== 0;
113
+ if (!input.includeArchived && !archiveVisible)
87
114
  conditions.push("n.archived_at IS NULL");
88
115
  const tasks = await deps.db
89
116
  .prepare(`${subtreeCte}
90
- SELECT n.id, n.title, t.status, t.assignee_id, t.labels_json,
117
+ SELECT n.id, n.title, n.parent_id, t.status, t.assignee_id, t.labels_json,
91
118
  t.start_date, t.due_date, t.depends_on, t.position, n.archived_at
92
119
  FROM board_tasks t
93
120
  JOIN nodes n ON n.id = t.node_id
@@ -96,6 +123,10 @@ export function createBoardRepository(deps) {
96
123
  ORDER BY t.status, t.position, n.id`)
97
124
  .bind(...walkBindings(actor, "read"), ...bindings)
98
125
  .all();
126
+ // ⚠️ The CONFIGURED columns only. The archive column is appended by the service, after the
127
+ // fallback for an unconfigured board has been applied — appended here it would make every
128
+ // board look configured, and a board with no columns of its own would answer with the
129
+ // archive and nothing else.
99
130
  const columns = parseList(board.statuses_json, "columns")
100
131
  .map((entry) => BoardColumn.safeParse(entry))
101
132
  .filter((parsed) => parsed.success)
@@ -103,8 +134,9 @@ export function createBoardRepository(deps) {
103
134
  return {
104
135
  boardId: board.id,
105
136
  title: board.title,
137
+ archiveVisible,
106
138
  columns,
107
- tasks: (tasks.results ?? []).map(mapTask),
139
+ tasks: (tasks.results ?? []).map((row) => mapTask(row, board.id)),
108
140
  };
109
141
  },
110
142
  async columnsOf(actor, boardId, verb) {
@@ -124,13 +156,17 @@ export function createBoardRepository(deps) {
124
156
  .filter((parsed) => parsed.success)
125
157
  .map((parsed) => parsed.data.id);
126
158
  },
127
- async setColumns(boardId, columnsJson, occurredAt) {
159
+ async setColumns(boardId, columnsJson, occurredAt, archiveVisible) {
160
+ // ⚠️ `COALESCE(?, archive_visible)` rather than a second statement: an absent flag means
161
+ // "leave it", and writing a default here would un-hide an archive somebody put away every
162
+ // time the columns are reordered.
128
163
  await deps.db
129
- .prepare(`INSERT INTO boards (node_id, statuses_json, created_at, updated_at)
130
- VALUES (?, ?, ?, ?)
164
+ .prepare(`INSERT INTO boards (node_id, statuses_json, archive_visible, created_at, updated_at)
165
+ VALUES (?, ?, COALESCE(?, 1), ?, ?)
131
166
  ON CONFLICT(node_id) DO UPDATE SET statuses_json = excluded.statuses_json,
167
+ archive_visible = COALESCE(?, boards.archive_visible),
132
168
  updated_at = excluded.updated_at`)
133
- .bind(boardId, columnsJson, occurredAt, occurredAt)
169
+ .bind(boardId, columnsJson, archiveVisible === undefined ? null : Number(archiveVisible), occurredAt, occurredAt, archiveVisible === undefined ? null : Number(archiveVisible))
134
170
  .run();
135
171
  },
136
172
  /**
@@ -154,7 +190,7 @@ export function createBoardRepository(deps) {
154
190
  async taskRow(actor, taskId, verb) {
155
191
  const row = await deps.db
156
192
  .prepare(`${subtreeCte}
157
- SELECT t.node_id, t.board_id, t.status, t.position
193
+ SELECT t.node_id, t.board_id, t.status, t.position, n.archived_at, n.updated_at
158
194
  FROM board_tasks t
159
195
  JOIN nodes n ON n.id = t.node_id
160
196
  JOIN allowed ON allowed.id = n.id
@@ -168,6 +204,8 @@ export function createBoardRepository(deps) {
168
204
  boardId: row.board_id,
169
205
  status: row.status,
170
206
  position: row.position,
207
+ archivedAt: row.archived_at,
208
+ updatedAt: row.updated_at,
171
209
  };
172
210
  },
173
211
  /**
@@ -176,6 +176,22 @@ export function createNodeRepository(deps) {
176
176
  withChildren: rows.filter((row) => row.has_children === 1).map((row) => row.id),
177
177
  };
178
178
  },
179
+ /**
180
+ * ⚠️ Deliberately NOT `visibleChildren`: that builder cuts tasks out of every level, so it
181
+ * answers `0` for a task with any number of subtasks. This one asks the question the guard
182
+ * actually has — "is anything still hanging under this node".
183
+ */
184
+ async countLiveChildren(nodeId) {
185
+ // ⚠️ No `allowed` join, deliberately: a grant can sit on a single node, so a child may be
186
+ // invisible to whoever archives its parent. Filtered by visibility this answers `0`, the
187
+ // refusal does not fire, and the child is later purged without being named. A count leaks a
188
+ // number and nothing else; the alternative leaks the child itself, permanently.
189
+ const result = await deps.db
190
+ .prepare("SELECT COUNT(*) AS total FROM nodes WHERE parent_id = ? AND archived_at IS NULL")
191
+ .bind(nodeId)
192
+ .first();
193
+ return result?.total ?? 0;
194
+ },
179
195
  async listVisibleBounded(actor, input) {
180
196
  const result = await deps.db
181
197
  .prepare(visibleChildren(true))
@@ -1,5 +1,19 @@
1
1
  import type { BoardDeps, BoardService } from "./boards.types.js";
2
+ /**
3
+ * What a board with no configuration answers with. A board is usable the moment it is created, and
4
+ * nothing asks whoever made it to define columns before filing the first card.
5
+ *
6
+ * ⚠️ **This is the FALLBACK, not what a board made through the application gets.** Columns are
7
+ * shared data: two people on one board must read the same names, and the settings dialog edits one
8
+ * list. So the application writes them at creation, in the language of whoever created it (#675),
9
+ * and from then on they are data anybody can rename. This list only answers for a board nobody
10
+ * configured — one made over MCP, or one whose seeding did not land.
11
+ */
2
12
  export declare const DEFAULT_COLUMNS: readonly [{
13
+ readonly id: "backlog";
14
+ readonly title: "Backlog";
15
+ readonly terminal: false;
16
+ }, {
3
17
  readonly id: "todo";
4
18
  readonly title: "Offen";
5
19
  readonly terminal: false;
@@ -12,5 +26,5 @@ export declare const DEFAULT_COLUMNS: readonly [{
12
26
  readonly title: "Fertig";
13
27
  readonly terminal: true;
14
28
  }];
15
- export declare const FIRST_DEFAULT_COLUMN: "todo";
29
+ export declare const FIRST_DEFAULT_COLUMN: "backlog";
16
30
  export declare function createBoards(deps: BoardDeps): BoardService;
@@ -1,7 +1,17 @@
1
+ import { ARCHIVE_COLUMN_ID } from "@anchrd/intel-contract/board";
1
2
  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.
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
+ */
4
13
  export const DEFAULT_COLUMNS = [
14
+ { id: "backlog", title: "Backlog", terminal: false },
5
15
  { id: "todo", title: "Offen", terminal: false },
6
16
  { id: "doing", title: "Läuft", terminal: false },
7
17
  { id: "done", title: "Fertig", terminal: true },
@@ -35,7 +45,35 @@ export function createBoards(deps) {
35
45
  // A board that was never configured answers with the default columns rather than none. An empty
36
46
  // column list would render as a board with no columns at all — a screen with nowhere to put a
37
47
  // card, which looks broken rather than new.
38
- return view.columns.length > 0 ? view : { ...view, columns: [...DEFAULT_COLUMNS] };
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
+ };
39
77
  };
40
78
  const requireColumn = (columns, status) => {
41
79
  if (columns.includes(status))
@@ -56,7 +94,28 @@ export function createBoards(deps) {
56
94
  if (duplicate !== undefined) {
57
95
  throw new IntelError(400, "duplicate_column", `The column id \`${duplicate}\` appears more than once.`);
58
96
  }
59
- await deps.boards.setColumns(input.boardId, JSON.stringify(input.columns), deps.now().toISOString());
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);
60
119
  return await viewOrRefuse(actor, { boardId: input.boardId, includeArchived: false });
61
120
  },
62
121
  async createTask(actor, input) {
@@ -67,11 +126,40 @@ export function createBoards(deps) {
67
126
  const status = input.status ?? columns[0] ?? DEFAULT_COLUMNS[0].id;
68
127
  if (input.status !== undefined)
69
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
+ }
70
155
  // ⚠️ Through the node service, not a second insert. The task gets the same authorization
71
156
  // check, the same audit row and the same version chain every other node gets — and that audit
72
157
  // row is what `anchrd/signals` reads (#620).
73
158
  const node = await deps.nodes.create(actor, {
74
- parentId: input.boardId,
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,
75
163
  kind: "task",
76
164
  title: input.title,
77
165
  description: null,
@@ -117,7 +205,26 @@ export function createBoards(deps) {
117
205
  if (row === null) {
118
206
  throw new IntelError(404, "task_not_found", "No task with this id is readable for you.");
119
207
  }
120
- if (input.status !== undefined) {
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) {
121
228
  requireColumn(await columnsOrRefuse(actor, row.boardId, "write"), input.status);
122
229
  }
123
230
  // Only what arrived. An absent field means "leave it"; `null` where the column is nullable
@@ -129,9 +236,16 @@ export function createBoards(deps) {
129
236
  // one would make two rows disagree about what a card is called.
130
237
  throw new IntelError(400, "title_belongs_to_the_node", "Rename a task with `node_update`; the board row carries no title.");
131
238
  }
132
- if (input.status !== undefined)
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)
133
242
  fields.status = input.status;
134
- if (input.position !== undefined)
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)
135
249
  fields.position = input.position;
136
250
  if (input.assigneeId !== undefined)
137
251
  fields.assignee_id = input.assigneeId;
@@ -152,9 +266,17 @@ export function createBoards(deps) {
152
266
  * on every attempt while every call reported success. That is the idempotency the tool
153
267
  * promises with `idempotentHint: true`, kept by construction rather than by a key lookup.
154
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
+ */
155
276
  if (input.status !== undefined &&
156
277
  input.position === undefined &&
157
- row.status !== input.status) {
278
+ row.status !== input.status &&
279
+ !intoArchive) {
158
280
  fields.position = await deps.boards.nextPosition(row.boardId, input.status);
159
281
  }
160
282
  if (Object.keys(fields).length > 0) {
@@ -7,6 +7,15 @@ export interface BoardTaskRow {
7
7
  boardId: string;
8
8
  status: string;
9
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;
10
19
  }
11
20
  export interface BoardTaskWrite {
12
21
  nodeId: string;
@@ -23,7 +32,12 @@ export interface BoardTaskWrite {
23
32
  export interface BoardRepository {
24
33
  read(actor: Actor, input: BoardGetInput): Promise<BoardView | null>;
25
34
  columnsOf(actor: Actor, boardId: string, verb: "read" | "write"): Promise<string[] | null>;
26
- setColumns(boardId: string, columnsJson: string, occurredAt: string): Promise<void>;
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>;
27
41
  attach(write: BoardTaskWrite): Promise<void>;
28
42
  taskRow(actor: Actor, taskId: string, verb: "read" | "write"): Promise<BoardTaskRow | null>;
29
43
  updateTask(taskId: string, fields: Record<string, string | number | null>, occurredAt: string): Promise<void>;
@@ -37,6 +51,17 @@ export interface BoardNodePort {
37
51
  description: null;
38
52
  idempotencyKey: string;
39
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>;
40
65
  }
41
66
  export interface BoardDeps {
42
67
  boards: BoardRepository;
@@ -53,6 +53,7 @@ const defaultLogo = `export const customLogoUrl: string | null = null;\n`;
53
53
  const headersFile = `# Generated by \`intel build\` — do not edit by hand.
54
54
  /*
55
55
  Cache-Control: no-store
56
+ Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
56
57
 
57
58
  /assets/*
58
59
  ! Cache-Control
package/dist/mcp/mcp.js CHANGED
@@ -182,7 +182,7 @@ export async function handleMcp(request, deps) {
182
182
  }, async (input) => text(await deps.boards.get(actor, input)));
183
183
  server.registerTool("board_task_create", {
184
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.",
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
186
  inputSchema: BoardTaskCreateInput,
187
187
  annotations: {
188
188
  title: "Add a card to a board",
@@ -194,7 +194,7 @@ export async function handleMcp(request, deps) {
194
194
  }, async (input) => text(await deps.boards.createTask(actor, input)));
195
195
  server.registerTool("board_task_update", {
196
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.",
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
198
  inputSchema: BoardTaskUpdateInput,
199
199
  annotations: {
200
200
  title: "Change a card",
@@ -83,20 +83,84 @@ function decodeBase64(value) {
83
83
  * ⚠️ The refusal names the KIND in the sentence rather than a bare "not a folder". Somebody
84
84
  * dragging a card into a folder has to learn what went wrong, not that something did.
85
85
  */
86
- function refuseWrongParent(childKind, parent, currentParentId) {
86
+ /**
87
+ * ⚠️ **Nothing may be filed under an ARCHIVED node**, and this is not tidiness — it is what keeps
88
+ * the purge honest.
89
+ *
90
+ * A parent may be archived while it is empty; `task_has_subtasks` only refuses one that still has
91
+ * live children. Without this rule it could then GAIN a live child afterwards, and `purge` — which
92
+ * asks only whether the node itself is archived — walks the whole subtree and takes that child
93
+ * with it. It would disappear for good without ever being named in the confirmation.
94
+ *
95
+ * Found in review of #669, against my own sentence claiming purge needed no rule of its own. That
96
+ * sentence was true only as long as an archived node could never gain a live child, and nothing
97
+ * made it true.
98
+ */
99
+ /**
100
+ * The board a node would be filed on: the board itself, or the board of the task it hangs under
101
+ * (#669).
102
+ *
103
+ * ⚠️ **One function, because there are three callers and they must not drift.** The create path,
104
+ * the move and the idempotent replay all ask the same question, and the replay was written when a
105
+ * task's parent was always its board — it passed `parentId` straight through as the board id. With
106
+ * nesting that files a subtask on a "board" that is another card, and `board_tasks.board_id` has no
107
+ * foreign key saying otherwise. Found in review of #669: the main path had been corrected and this
108
+ * one had not, which is the shape of `anchrd/intel#457` — two places answering one question, only
109
+ * one of them told about the change.
110
+ */
111
+ async function boardFor(deps, actor, parent) {
112
+ if (parent === null)
113
+ return null;
114
+ if (parent.kind === "board")
115
+ return parent.id;
116
+ // ⚠️ Only a TASK can have a board, and asking for anything else costs a D1 round trip on every
117
+ // write in the whole tree — a document filed in a folder would pay for a board feature. The
118
+ // question is not "do we know the board" but "can this parent have one at all".
119
+ if (parent.kind !== "task")
120
+ return null;
121
+ return (await deps.boardOfTask?.(actor, parent.id)) ?? null;
122
+ }
123
+ function refuseArchivedParent(childKind, parent) {
124
+ // ⚠️ Tasks only, and the narrowness is deliberate. The same hole exists for a folder — archive an
125
+ // empty one, file a document in it, purge — but it is OLDER than this ticket and fixing it here
126
+ // would change the behaviour of the whole tree out of a board ticket, with no test outside
127
+ // `boards.int.ts` covering it. It is #679, which also asks what such data already exists.
128
+ //
129
+ // ⚠️ A reference without its number is not a reference: the review of #677 searched for this
130
+ // ticket and could not find it, because this comment did not name it.
131
+ if (childKind !== "task")
132
+ return;
133
+ if (parent === null || parent.archivedAt === null)
134
+ return;
135
+ throw new IntelError(409, "parent_archived", `This ${parent.kind} is archived, so nothing new can be filed under it. Restore it first.`);
136
+ }
137
+ function refuseWrongParent(childKind, parent, boards) {
87
138
  if (childKind === "task") {
88
139
  if (parent === null) {
89
140
  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
141
  }
91
- if (parent.kind !== "board") {
142
+ // ⚠️ Since #669 a task may sit under a task — the hierarchy is the node tree. Everything else
143
+ // is still refused by name.
144
+ if (parent.kind !== "board" && parent.kind !== "task") {
92
145
  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
146
  }
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) {
147
+ /**
148
+ * ⚠️ **The same BOARD, not the same parent node and that difference is what #669 changed.**
149
+ * Until then the two were interchangeable, because a task's parent always WAS its board; the
150
+ * check compared parent ids and was right by accident. With nesting, moving a card under a
151
+ * sibling changes its parent legitimately while the board stays the same, and comparing ids
152
+ * would refuse a move that is allowed.
153
+ *
154
+ * The rule it protects is unchanged: `board_tasks.board_id` does not travel with a move through
155
+ * the node path, so a card handed to another board would answer from one board's query while
156
+ * its row names the other — and the card would show up on neither, or on both.
157
+ */
158
+ if (boards?.destination === null || boards?.destination === undefined) {
159
+ throw new IntelError(409, "task_belongs_to_a_board", "This destination has no board, so a card cannot be filed under it.");
160
+ }
161
+ if (boards.current !== undefined &&
162
+ boards.current !== null &&
163
+ boards.destination !== boards.current) {
100
164
  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
165
  }
102
166
  return;
@@ -512,7 +576,12 @@ export function createNodes(deps) {
512
576
  * than in a sweeper somebody has to remember to write.
513
577
  */
514
578
  if (existing.kind === "task" && existing.parentId !== null) {
515
- await deps.attachToBoard?.(actor, existing.id, existing.parentId, deps.now().toISOString());
579
+ // ⚠️ The BOARD, resolved the same way the create path resolves it. Passing `parentId`
580
+ // straight through was right only while a task's parent was always its board.
581
+ const board = await boardFor(deps, actor, await requireVisible(actor, existing.parentId));
582
+ if (board !== null) {
583
+ await deps.attachToBoard?.(actor, existing.id, board, deps.now().toISOString());
584
+ }
516
585
  }
517
586
  return existing;
518
587
  }
@@ -522,7 +591,13 @@ export function createNodes(deps) {
522
591
  // ⚠️ The same rule on the way IN, and it runs for the ROOT as well. Guarding only a named
523
592
  // parent leaves `parentId: null` open, and that is not a smaller hole: the task is created,
524
593
  // `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));
594
+ const destinationParent = input.parentId === null ? null : await requireVisible(actor, input.parentId);
595
+ const destinationBoard = await boardFor(deps, actor, destinationParent);
596
+ // ⚠️ Kind first, archive second. A task under an archived FOLDER is refused for the deeper
597
+ // reason — a task lives on a board — and saying "restore the folder first" would send the
598
+ // reader to do something that changes nothing: restored, the folder still cannot hold a task.
599
+ refuseWrongParent(input.kind, destinationParent, { destination: destinationBoard });
600
+ refuseArchivedParent(input.kind, destinationParent);
526
601
  const timestamp = deps.now().toISOString();
527
602
  const created = await deps.repository.insertNode({
528
603
  node: {
@@ -556,8 +631,10 @@ export function createNodes(deps) {
556
631
  * The port is optional because the node service must not require a board to exist: the CLI
557
632
  * and the bundle importer build one without that half of the world.
558
633
  */
559
- if (created.kind === "task" && input.parentId !== null) {
560
- await deps.attachToBoard?.(actor, created.id, input.parentId, timestamp);
634
+ // ⚠️ `destinationBoard`, not `input.parentId`. Since #669 the parent may be another task, and
635
+ // filing the card under its parent's ID would put a subtask on a "board" that is a card.
636
+ if (created.kind === "task" && destinationBoard !== null) {
637
+ await deps.attachToBoard?.(actor, created.id, destinationBoard, timestamp);
561
638
  }
562
639
  return created;
563
640
  },
@@ -878,10 +955,35 @@ export function createNodes(deps) {
878
955
  throw new IntelError(409, "move_cycle", "A node cannot contain itself");
879
956
  }
880
957
  const parent = input.parentId === null ? null : await requireVisible(actor, input.parentId);
881
- refuseWrongParent(current.kind, parent, current.parentId);
958
+ /**
959
+ * ⚠️ **The write check on the destination comes FIRST, before the board is resolved.**
960
+ * `boardOfTask` asks with the verb `write`, so for a destination this actor may only read
961
+ * it answers `null` — and the refusal below would then say "this destination has no board"
962
+ * about a card that has one perfectly well. The honest answer is 403, and it is the order
963
+ * `packages/api/CLAUDE.md` prescribes anyway: capability, then resource, then action.
964
+ */
882
965
  if (parent !== null && !(await deps.repository.can(actor, parent.id, "write"))) {
883
966
  throw new IntelError(403, "node_forbidden", "Destination folder cannot be edited");
884
967
  }
968
+ const destinationBoard = await boardFor(deps, actor, parent);
969
+ /**
970
+ * ⚠️ **A board that cannot be resolved is refused, not skipped.** Without this line an
971
+ * installation whose board port is absent — or one where the actor may not write the
972
+ * board — would answer `undefined`, the comparison below would read that as "not a move
973
+ * between boards", and a card could be handed to any board at all. The integration test
974
+ * `refuses the three ways a card could leave its board` caught exactly that: the test
975
+ * harness did not wire the port, and door 3 stood open again.
976
+ */
977
+ const currentBoard = current.kind === "task" ? ((await deps.boardOfTask?.(actor, current.id)) ?? null) : null;
978
+ if (current.kind === "task" && currentBoard === null) {
979
+ throw new IntelError(409, "task_cannot_change_board", "This card's board cannot be resolved, so it cannot be moved. A card belongs to the board it was filed on.");
980
+ }
981
+ // Kind first, archive second — see the same pair in `create`.
982
+ refuseWrongParent(current.kind, parent, {
983
+ destination: destinationBoard,
984
+ current: currentBoard,
985
+ });
986
+ refuseArchivedParent(current.kind, parent);
885
987
  }
886
988
  const updatedAt = deps.now().toISOString();
887
989
  const updated = await deps.repository.updateNode({
@@ -929,6 +1031,27 @@ export function createNodes(deps) {
929
1031
  // A replay does nothing a second time and answers with what the first run made.
930
1032
  if (replayedId)
931
1033
  return await requireVisible(actor, replayedId);
1034
+ /**
1035
+ * ⚠️ **A task with live subtasks is refused, not archived with them** (#669).
1036
+ * `destructive.md`: *verweigern schlägt kaskadieren* — a cascade turns one confirmed click
1037
+ * into an unknown number of disappearances, and the question that was asked ("archive this
1038
+ * card") cannot then name what actually vanishes.
1039
+ *
1040
+ * ⚠️ It is refused only ON THE WAY IN. Restoring a parent must stay possible, or a card
1041
+ * archived together with its children by an older path could never come back.
1042
+ *
1043
+ * The other half — `purge` — needs no rule of its own, but ONLY together with
1044
+ * `refuseArchivedParent` below: only an archived node may be purged, a parent cannot reach
1045
+ * that state while a child is live, and nothing new may be filed under it afterwards. Take
1046
+ * any one of the three away and a live card can be deleted for good without being named —
1047
+ * which is what the review of #669 found in the first version of this sentence.
1048
+ */
1049
+ if (input.archived && current.kind === "task") {
1050
+ const children = await deps.repository.countLiveChildren(current.id);
1051
+ if (children > 0) {
1052
+ throw new IntelError(409, "task_has_subtasks", `This card still has ${children} subtask(s). Archive them first, or move them somewhere else — archiving a card does not archive what hangs under it.`);
1053
+ }
1054
+ }
932
1055
  const updatedAt = deps.now().toISOString();
933
1056
  const updated = await deps.repository.archiveNode({
934
1057
  nodeId: current.id,
@@ -55,6 +55,23 @@ export interface NodeRepository {
55
55
  limit: number;
56
56
  }): Promise<BoundedChildren>;
57
57
  getVisible(actor: Actor, nodeId: string): Promise<Node | null>;
58
+ /**
59
+ * How many live children this node has — **every kind, tasks included** (#669).
60
+ *
61
+ * ⚠️ **`listVisibleBounded` cannot answer this**, and the difference is invisible at the call
62
+ * site: that query runs through `visibleChildren`, which cuts `kind = 'task'` out of every level
63
+ * so a board shows no cards in the tree (D66). Asked about a task, it therefore answers `0` for
64
+ * every task that ever existed — a guard built on it would never fire once, and nothing would
65
+ * report that. Two queries with similar names are two different questions
66
+ * (`destructive.md`, `anchrd/intel#457`).
67
+ *
68
+ * ⚠️ **It takes no actor, and that is the second half of the same lesson.** A grant can sit on a
69
+ * single node (ADR-0004 §2), so a child may be invisible to whoever archives its parent. Counted
70
+ * through the visibility predicate, such a child reads as `0`, the refusal does not fire, the
71
+ * parent becomes purgeable — and the child is deleted for good without ever being named. The
72
+ * question here is "is anything still hanging under this node", not "what may this actor see".
73
+ */
74
+ countLiveChildren(nodeId: string): Promise<number>;
58
75
  listVisibleSubtree(actor: Actor, rootId: string | null): Promise<SubtreeNode[]>;
59
76
  can(actor: Actor, nodeId: string, verb: ResourceVerb): Promise<boolean>;
60
77
  findIdempotentNode(actorId: string, operation: "node.create" | "node.save" | "node.append" | "node.update" | "node.archive" | "node.share" | "node.revoke" | SnapshotOperation, idempotencyKey: string): Promise<string | null>;
@@ -223,6 +240,18 @@ export interface NodesDeps {
223
240
  * and gets filed later, never that the node is refused.
224
241
  */
225
242
  attachToBoard?(actor: Actor, taskId: string, boardId: string, occurredAt: string): Promise<void>;
243
+ /**
244
+ * Which board a task belongs to (#669).
245
+ *
246
+ * ⚠️ Needed since a task may sit under another task: the destination of a move is then not the
247
+ * board itself, and "does this card change boards" cannot be answered from the node row alone.
248
+ *
249
+ * Optional for the same reason as `attachToBoard` — the CLI and the bundle importer have no
250
+ * board half. ⚠️ **Where it is absent, filing a task UNDER a task is refused** rather than
251
+ * allowed unchecked: the rule it would skip is the one that keeps a card from silently changing
252
+ * boards.
253
+ */
254
+ boardOfTask?(actor: Actor, taskId: string): Promise<string | null>;
226
255
  content: ContentStore;
227
256
  id(): string;
228
257
  now(): Date;
@@ -0,0 +1,33 @@
1
+ -- The archive as a column every board has (D68, #674).
2
+ --
3
+ -- ⚠️ Only the VISIBILITY is stored. Which cards are in the archive is `nodes.archived_at` and
4
+ -- nothing else — a second column holding "is archived" would be the two truths D68 exists against.
5
+ -- A card is in the archive column BECAUSE it is archived; dropping it there archives it, and
6
+ -- pulling it out restores it into the working column `board_tasks.status` still names.
7
+ --
8
+ -- ⚠️ Default 1: the column is there on every board unless somebody hides it. A board that predates
9
+ -- this migration therefore shows it too, which is the decided behaviour rather than an accident —
10
+ -- an archive nobody can see is an archive nobody empties.
11
+ ALTER TABLE boards ADD COLUMN archive_visible INTEGER NOT NULL DEFAULT 1;
12
+
13
+ -- ⚠️ **Existing data.** Until this migration `BoardColumn.id` was a free string: a board could
14
+ -- configure a column called `archived` itself. From here the name is reserved, and without this
15
+ -- step such a board would show TWO columns with one id — the stored one and the derived one — and
16
+ -- the cards of both would be indistinguishable.
17
+ --
18
+ -- ⚠️ **Every row with that status, not only the ones whose board still configures it.** Columns are
19
+ -- REPLACED rather than merged, so a board may have used the column and dropped it again; those
20
+ -- cards still carry `status = 'archived'` while `statuses_json` has long forgotten it. Left alone
21
+ -- they would surface in the archive column with `archived_at` still `null` — a card that looks
22
+ -- archived, is not, and cannot be restored, because there is nothing to restore.
23
+ --
24
+ -- The new name is deliberately ugly, because it is meant to be seen: whoever reads it knows
25
+ -- something was renamed here and can put it right in the settings.
26
+ UPDATE board_tasks SET status = 'archived_column_renamed' WHERE status = 'archived';
27
+
28
+ UPDATE boards
29
+ SET statuses_json = replace(statuses_json, '"id":"archived"', '"id":"archived_column_renamed"')
30
+ WHERE node_id IN (
31
+ SELECT b.node_id FROM boards b, json_each(b.statuses_json)
32
+ WHERE json_extract(json_each.value, '$.id') = 'archived'
33
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.28.0",
3
+ "version": "0.30.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.21.0",
46
- "@anchrd/intel-contract": "^0.23.0",
46
+ "@anchrd/intel-contract": "^0.24.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",