@anchrd/intel-api 0.35.0 → 0.36.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.
@@ -247,6 +247,18 @@ export function createBoardRepository(deps) {
247
247
  * membership to anyone who can read it; what leaves this method cannot be turned back into who
248
248
  * granted what to whom.
249
249
  *
250
+ * ⚠️ **A grant can name an EMAIL instead of a user id, and both are real access** (#757). The
251
+ * first version of this counted only `principal_type = 'user'` and therefore showed nobody on a
252
+ * board shared by address. Measured on the installation on 2026-08-23: four of nine grants were
253
+ * `email` ones.
254
+ *
255
+ * ⚠️ **Not lowercased here, and that is measured rather than assumed.** The write path already
256
+ * normalises an address twice (`nodes.ts` before the write, `db-grants.ts` at the boundary), and
257
+ * the installation confirms it: every stored `email` grant is lowercase. A `lower()` here would
258
+ * be defence against data that cannot exist, and no test could tell it from a no-op. The side
259
+ * that DOES need folding is the directory hit, because gate stores what somebody typed at
260
+ * signup and its casing is not this repository's to decide.
261
+ *
250
262
  * ⚠️ `verb = 'read'`, and that is not a detail. The verbs here are granted INDEPENDENTLY
251
263
  * (ADR-0004 §2): `write` does not follow from `read` and `read` does not follow from `write`.
252
264
  * Counting any verb would offer somebody who holds only `write` or `share` as an assignee for a
@@ -279,17 +291,32 @@ export function createBoardRepository(deps) {
279
291
  AND grant_row.verb = 'read'
280
292
  AND ${grantInForce}
281
293
  )) AS principal_ids_json,
294
+ (SELECT json_group_array(principal_id)
295
+ FROM (
296
+ SELECT DISTINCT grant_row.principal_id
297
+ FROM node_grants grant_row
298
+ JOIN ancestors ON ancestors.id = grant_row.node_id
299
+ WHERE grant_row.principal_type = 'email'
300
+ AND grant_row.principal_id IS NOT NULL
301
+ AND grant_row.verb = 'read'
302
+ AND ${grantInForce}
303
+ )) AS principal_emails_json,
282
304
  (SELECT COUNT(*)
283
305
  FROM node_grants grant_row
284
306
  JOIN ancestors ON ancestors.id = grant_row.node_id
285
307
  WHERE grant_row.principal_type = 'organization'
286
308
  AND grant_row.verb = 'read'
287
309
  AND ${grantInForce}) AS organization_grants`)
288
- .bind(boardId, deps.now().toISOString(), deps.now().toISOString())
310
+ // ⚠️ ONE `now` per `grantInForce`, and there are three of them since #757: the user
311
+ // grants, the email grants and the organization count. A missing binding is not a wrong
312
+ // answer here, it is `D1_ERROR: Wrong number of parameter bindings` — loud, which is the
313
+ // good case. Read the count off the statement, never off memory.
314
+ .bind(boardId, deps.now().toISOString(), deps.now().toISOString(), deps.now().toISOString())
289
315
  .first();
290
316
  return {
291
317
  ownerIds: JSON.parse(row?.owner_ids_json ?? "[]"),
292
318
  principalIds: JSON.parse(row?.principal_ids_json ?? "[]"),
319
+ principalEmails: JSON.parse(row?.principal_emails_json ?? "[]"),
293
320
  organizationWide: (row?.organization_grants ?? 0) > 0,
294
321
  };
295
322
  },
@@ -9,22 +9,36 @@ import type { BoardDeps, BoardService } from "./boards.types.js";
9
9
  * and from then on they are data anybody can rename. This list only answers for a board nobody
10
10
  * configured — one made over MCP, or one whose seeding did not land.
11
11
  */
12
+ /**
13
+ * ⚠️ **The four start coloured, and the colours mean something** (Jack's decision 2026-08-22): grey
14
+ * for what is only lying there, yellow for what is ready, orange for what is being worked on, green
15
+ * for what is done. That order is the one people already read without a legend, which is the whole
16
+ * reason a default is worth having — a board that starts grey teaches nothing, and one that starts
17
+ * in arbitrary colours teaches something false.
18
+ *
19
+ * They are a suggestion, not a setting: every one of them can be overwritten, and a board that has
20
+ * been recoloured keeps its own values.
21
+ */
12
22
  export declare const DEFAULT_COLUMNS: readonly [{
13
23
  readonly id: "backlog";
14
24
  readonly title: "Backlog";
15
25
  readonly terminal: false;
26
+ readonly color: "#9ca3af";
16
27
  }, {
17
28
  readonly id: "todo";
18
29
  readonly title: "Offen";
19
30
  readonly terminal: false;
31
+ readonly color: "#eab308";
20
32
  }, {
21
33
  readonly id: "doing";
22
34
  readonly title: "Läuft";
23
35
  readonly terminal: false;
36
+ readonly color: "#f97316";
24
37
  }, {
25
38
  readonly id: "done";
26
39
  readonly title: "Fertig";
27
40
  readonly terminal: true;
41
+ readonly color: "#22c55e";
28
42
  }];
29
43
  export declare const FIRST_DEFAULT_COLUMN: "backlog";
30
44
  export declare function createBoards(deps: BoardDeps): BoardService;
@@ -1,4 +1,4 @@
1
- import { ARCHIVE_COLUMN_ID } from "@anchrd/intel-contract/board";
1
+ import { ARCHIVE_COLUMN_ID, DEFAULT_COLUMN_COLOR } from "@anchrd/intel-contract/board";
2
2
  import { IntelError } from "../shared/intel-error/intel-error.js";
3
3
  /**
4
4
  * What a board with no configuration answers with. A board is usable the moment it is created, and
@@ -10,11 +10,21 @@ import { IntelError } from "../shared/intel-error/intel-error.js";
10
10
  * and from then on they are data anybody can rename. This list only answers for a board nobody
11
11
  * configured — one made over MCP, or one whose seeding did not land.
12
12
  */
13
+ /**
14
+ * ⚠️ **The four start coloured, and the colours mean something** (Jack's decision 2026-08-22): grey
15
+ * for what is only lying there, yellow for what is ready, orange for what is being worked on, green
16
+ * for what is done. That order is the one people already read without a legend, which is the whole
17
+ * reason a default is worth having — a board that starts grey teaches nothing, and one that starts
18
+ * in arbitrary colours teaches something false.
19
+ *
20
+ * They are a suggestion, not a setting: every one of them can be overwritten, and a board that has
21
+ * been recoloured keeps its own values.
22
+ */
13
23
  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 },
24
+ { id: "backlog", title: "Backlog", terminal: false, color: DEFAULT_COLUMN_COLOR },
25
+ { id: "todo", title: "Offen", terminal: false, color: "#eab308" },
26
+ { id: "doing", title: "Läuft", terminal: false, color: "#f97316" },
27
+ { id: "done", title: "Fertig", terminal: true, color: "#22c55e" },
18
28
  ];
19
29
  // The column a card lands in when nobody said and the board has none configured. One export rather
20
30
  // than the literal `"todo"` in three places: the fallback exists FOR the unconfigured board, so a
@@ -24,6 +34,26 @@ export const FIRST_DEFAULT_COLUMN = DEFAULT_COLUMNS[0].id;
24
34
  // is a listing under a different name — the same floor gate holds at its own door, stated here so
25
35
  // this surface keeps its own promise (#700).
26
36
  const MINIMUM_QUERY = 2;
37
+ // How many people a picker offers before anybody types. Three is a suggestion, not a directory: a
38
+ // longer list is one somebody reads instead of typing the name they already know (#757).
39
+ const SUGGESTIONS = 3;
40
+ /**
41
+ * Whether one person from the directory reaches this board (#700, #757).
42
+ *
43
+ * ⚠️ **Two ways in, and the second one cost a release to notice.** A grant names either a user id or
44
+ * an EMAIL, and both are real access. Counting only ids showed nobody on a board shared by address,
45
+ * which is how four of the nine grants on the reference installation are written.
46
+ *
47
+ * The address is compared lowercased on both sides: a grant written `Anton@…` and a directory hit
48
+ * that says `anton@…` are the same person, and a case-sensitive compare would quietly disagree.
49
+ */
50
+ function reachedBy(access, person) {
51
+ if (access.ownerIds.includes(person.id))
52
+ return true;
53
+ if (access.principalIds.includes(person.id))
54
+ return true;
55
+ return access.principalEmails.includes(person.email.toLowerCase());
56
+ }
27
57
  export function createBoards(deps) {
28
58
  /**
29
59
  * ⚠️ The one place "which board, and may this actor DO THIS to it" is answered — and the verb is
@@ -84,6 +114,61 @@ export function createBoards(deps) {
84
114
  return;
85
115
  throw new IntelError(400, "unknown_column", `This board has no column \`${status}\`. Its columns are: ${columns.join(", ")}.`);
86
116
  };
117
+ /**
118
+ * The people this board can be handed to, without a search term (#757).
119
+ *
120
+ * ⚠️ **Named, not enumerated.** Gate's directory answers searches, not listings, so what can be
121
+ * offered here is exactly the set this repository already knows by name: the owners, the user
122
+ * grants, and the addresses of the email grants. An organization-wide board therefore suggests
123
+ * whatever explicit grants it also has and nothing more — "everybody" is not a list anyone can
124
+ * produce, and pretending otherwise would mean asking gate to hand over its directory.
125
+ *
126
+ * ⚠️ **The cap is visible.** `more` says how many are left over, so the picker can say "and 4
127
+ * more" instead of quietly showing three and looking complete.
128
+ */
129
+ const suggest = async (boardId) => {
130
+ if (!deps.directory)
131
+ return { items: [], more: 0 };
132
+ const access = await deps.boards.effectiveAccess(boardId);
133
+ const ids = [...new Set([...access.ownerIds, ...access.principalIds])];
134
+ // One call for every id at once. The addresses need one lookup each, because gate resolves ids
135
+ // and searches text and there is no third door — so only as many as it takes to FILL the cap.
136
+ const byId = ids.length === 0 ? [] : await deps.directory.resolve(ids);
137
+ const knownEmails = new Set(byId.map((person) => person.email.toLowerCase()));
138
+ /**
139
+ * ⚠️ **The count and the list are two different things, and the count comes FIRST.**
140
+ *
141
+ * An earlier version stopped looking after the cap and then reported the leftovers it happened
142
+ * to have — so a board with ten address grants said "and 1 more" instead of "and 7 more". The
143
+ * cap was visible and its number was wrong, which is worse than no number: it is a number
144
+ * somebody believes.
145
+ *
146
+ * Counted here without naming anybody: every address that is not already one of the resolved
147
+ * people is one more person who reaches this board. Whether gate can name them does not change
148
+ * how many there are.
149
+ */
150
+ const unnamedAddresses = access.principalEmails.filter((address) => !knownEmails.has(address));
151
+ const total = byId.length + unnamedAddresses.length;
152
+ /**
153
+ * ⚠️ **The cap counts LOOKUPS, not names found**, and the difference is a real hole. An address
154
+ * gate cannot name never grows `named`, so a bound on the names would never trigger: a board
155
+ * with a hundred address grants and no matching accounts would fire a hundred sequential calls
156
+ * to gate to open one menu. The test that sets six unnameable addresses walks exactly that path.
157
+ */
158
+ const named = [...byId];
159
+ let lookups = 0;
160
+ for (const address of unnamedAddresses) {
161
+ if (named.length >= SUGGESTIONS || lookups >= SUGGESTIONS)
162
+ break;
163
+ lookups += 1;
164
+ const found = await deps.directory.search(address);
165
+ const exact = found.find((person) => person.email.toLowerCase() === address);
166
+ if (exact !== undefined)
167
+ named.push(exact);
168
+ }
169
+ const items = named.slice(0, SUGGESTIONS);
170
+ return { items, more: Math.max(0, total - items.length) };
171
+ };
87
172
  return {
88
173
  async get(actor, input) {
89
174
  return await viewOrRefuse(actor, input);
@@ -308,7 +393,7 @@ export function createBoards(deps) {
308
393
  // read gives, so a board one may not see stays indistinguishable from one that is not there.
309
394
  await columnsOrRefuse(actor, input.boardId, "read");
310
395
  if (!deps.directory)
311
- return { items: [] };
396
+ return { items: [], more: 0 };
312
397
  /**
313
398
  * ⚠️ ENFORCED HERE, not only described. The contract's `query` says a single character
314
399
  * answers empty, and until this line that sentence was true only because gate happens to
@@ -317,19 +402,31 @@ export function createBoards(deps) {
317
402
  * It is also the cheaper answer: a letter that can only ever produce a listing does not
318
403
  * need to travel to gate first to be refused there.
319
404
  */
320
- if (input.query.trim().length < MINIMUM_QUERY)
321
- return { items: [] };
322
- const hits = await deps.directory.search(input.query);
405
+ const query = input.query.trim();
406
+ /**
407
+ * ⚠️ **Nothing typed is a QUESTION, not a refusal** (#757). The set of people who reach this
408
+ * board is short and known, so it can be named without a search term — and a picker that
409
+ * opens empty teaches the reader that there is nobody, which is the opposite of what it
410
+ * means.
411
+ *
412
+ * A single character stays refused, and the reason is unchanged: a letter is not a search, it
413
+ * is a listing under a different name. The two cases differ because one names a bounded set
414
+ * and the other asks the directory to filter one.
415
+ */
416
+ if (query.length === 0)
417
+ return await suggest(input.boardId);
418
+ if (query.length < MINIMUM_QUERY)
419
+ return { items: [], more: 0 };
420
+ const hits = await deps.directory.search(query);
323
421
  if (hits.length === 0)
324
- return { items: [] };
422
+ return { items: [], more: 0 };
325
423
  const access = await deps.boards.effectiveAccess(input.boardId);
326
424
  // A grant to the organization means everybody reaches the board, so there is nothing left to
327
425
  // narrow — and narrowing anyway would produce an empty picker on exactly the boards that are
328
426
  // shared with everyone, which is most of them.
329
427
  if (access.organizationWide)
330
- return { items: hits };
331
- const reaches = new Set([...access.ownerIds, ...access.principalIds]);
332
- return { items: hits.filter((hit) => reaches.has(hit.id)) };
428
+ return { items: hits, more: 0 };
429
+ return { items: hits.filter((hit) => reachedBy(access, hit)), more: 0 };
333
430
  },
334
431
  /**
335
432
  * What the people already recorded on cards are called (#258, #700).
@@ -56,6 +56,7 @@ export interface BoardRepository {
56
56
  effectiveAccess(boardId: string): Promise<{
57
57
  ownerIds: string[];
58
58
  principalIds: string[];
59
+ principalEmails: string[];
59
60
  organizationWide: boolean;
60
61
  }>;
61
62
  }
@@ -101,8 +102,14 @@ export interface BoardService {
101
102
  update(actor: Actor, input: BoardUpdateInput): Promise<BoardView>;
102
103
  createTask(actor: Actor, input: BoardTaskCreateInput): Promise<BoardView>;
103
104
  updateTask(actor: Actor, input: BoardTaskUpdateInput): Promise<BoardView>;
105
+ /**
106
+ * ⚠️ `more` says how many people reach this board beyond the ones named, so a capped list can say
107
+ * so instead of looking complete (#757). It is `0` on a search answer, where the cap belongs to
108
+ * gate and not to this door.
109
+ */
104
110
  searchAssignees(actor: Actor, input: BoardAssigneeSearchInput): Promise<{
105
111
  items: BoardAssignee[];
112
+ more: number;
106
113
  }>;
107
114
  resolveAssignees(actor: Actor, input: BoardAssigneeResolveInput): Promise<{
108
115
  items: BoardAssignee[];
package/dist/mcp/mcp.js CHANGED
@@ -192,7 +192,7 @@ export async function handleMcp(request, deps) {
192
192
  // `list`, and a filter does not change which verb a call is.
193
193
  server.registerTool("board_assignee_list", {
194
194
  title: "Who a card on this board can be given to",
195
- description: "Find the people you may set as assignee on a card of this board, by typing part of a name or an address. Only people who can actually open THIS board are offered, so the same query against two boards can give two different answers. Under two characters the answer is empty rather than everybody. Use board_assignee_resolve to name somebody already on a card.",
195
+ description: "Find the people you may set as assignee on a card of this board. Only people who can actually open THIS board are offered, so the same query against two boards can give two different answers. Leave the query EMPTY to get up to three of them without searching, plus a count of how many more there are. One character answers empty rather than everybody; from two on it filters. Use board_assignee_resolve to name somebody already on a card.",
196
196
  inputSchema: BoardAssigneeSearchInput,
197
197
  annotations: {
198
198
  title: "Who a card on this board can be given to",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.35.0",
3
+ "version": "0.36.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.25.0",
46
- "@anchrd/intel-contract": "^0.27.0",
46
+ "@anchrd/intel-contract": "^0.28.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",