@anchrd/intel-api 0.35.0 → 0.37.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
  },
@@ -1,23 +1,5 @@
1
1
  import type { FeedRepository } from "../../feed/feed.types.js";
2
2
  import type { D1Database } from "./db.types.js";
3
- /**
4
- * One page of the journal, newest first, cut down to the rows this actor may see.
5
- *
6
- * ⚠️ **The visibility check is the same JOIN against `allowed` that `audit_list` uses**, which is
7
- * the same tree walk `nodes` reads through (`db-grants.ts`). An event about a node is visible
8
- * exactly when the node is. A second, similar-looking query beside the first is the drift that
9
- * `anchrd/intel#457` was built out of: two queries with similar names are two different questions.
10
- *
11
- * ⚠️ **`ORDER BY` and the cursor comparison name BOTH columns, in the same direction.** Two events
12
- * in the same millisecond are the normal case inside a batch, not the exception; on `occurred_at`
13
- * alone the second one is skipped in silence. SQLite has no row-value comparison here, so the
14
- * tuple comparison is written out: "earlier timestamp, OR same timestamp and smaller id".
15
- *
16
- * ⚠️ **Both columns descend, and that is what lets the index carry this.** `audit_events_feed_idx`
17
- * is `(resource_type, occurred_at, id)` ascending; SQLite reads an index backwards only when every
18
- * sort column is reversed together. Reversing one would cost a sort over the whole table, and that
19
- * is invisible on the first pages and only hurts far down.
20
- */
21
3
  export declare const feedPageQuery: string;
22
4
  /**
23
5
  * The folders above each of a page's nodes, and only the ones the actor may open.
@@ -32,6 +14,14 @@ export declare const feedPageQuery: string;
32
14
  * and `UNION` ends the walk because the row repeats — a depth counter would make every row unique
33
15
  * and turn the same cycle into a query that never returns. The order is rebuilt in TypeScript from
34
16
  * `parent_id`, where a visited-set makes the cycle harmless.
17
+ *
18
+ * ⚠️ **The seeds are the PARENTS, not the nodes** (#645). Starting at the node and stepping up to
19
+ * its parent works only while the node exists, and the one event that most needs a trail is the one
20
+ * whose node is gone. Starting a level higher costs nothing for the ordinary case — the parent is
21
+ * where the walk went anyway — and it is the only anchor a deletion has left.
22
+ *
23
+ * There is no `seed` column: ids are unique, so one flat set of visible ancestors serves every card
24
+ * on the page, and each trail is rebuilt from its own `parentId` through that set.
35
25
  */
36
26
  export declare const feedAncestorsQuery: (seeds: number) => string;
37
27
  export declare function createFeedRepository(deps: {
@@ -1,9 +1,12 @@
1
- import { FeedAction } from "@anchrd/intel-contract/feed";
2
- import { subtreeBindings, subtreeCte } from "./db-grants.js";
3
- // Built once from the contract enum rather than written out here: two lists of actions that have to
4
- // agree would eventually stop agreeing, and the one that silently wins is the SQL.
5
- const ACTIONS = FeedAction.options;
6
- const ACTION_PLACEHOLDERS = ACTIONS.map(() => "?").join(", ");
1
+ import { FeedAction, feedKindOf, } from "@anchrd/intel-contract/feed";
2
+ import { flowInSubtreeBindings, flowInSubtreeOver, subtreeBindings, subtreeCte, } from "./db-grants.js";
3
+ // The two kinds of resource the feed reads, split out of the one action list so the SQL can name
4
+ // each side by itself. Derived from the contract rather than written again here: one list that has
5
+ // to agree with another is one list too many.
6
+ const NODE_ACTIONS = FeedAction.options.filter((action) => feedKindOf(action) === "node");
7
+ const FLOW_ACTIONS = FeedAction.options.filter((action) => feedKindOf(action) === "flow");
8
+ const NODE_PLACEHOLDERS = NODE_ACTIONS.map(() => "?").join(", ");
9
+ const FLOW_PLACEHOLDERS = FLOW_ACTIONS.map(() => "?").join(", ");
7
10
  function parseMetadata(raw) {
8
11
  // The column is `NOT NULL DEFAULT '{}'` but it is written by eleven call sites over
9
12
  // `JSON.stringify` on whatever each had at hand. A row that cannot be parsed must not take the
@@ -29,24 +32,72 @@ function parseMetadata(raw) {
29
32
  * exactly when the node is. A second, similar-looking query beside the first is the drift that
30
33
  * `anchrd/intel#457` was built out of: two queries with similar names are two different questions.
31
34
  *
35
+ * ⚠️ **Two events have no row left to check, and that is why the join reads `COALESCE`** (#645 for
36
+ * `node.purge`, #763 for `flows.purge`).
37
+ * After `node.purge` the row is gone, so `JOIN nodes` dropped the event for everybody — creation,
38
+ * change and archiving arrived, final deletion never did. It now falls back to the `parentId` the
39
+ * purge wrote into its own metadata, and the check runs against that surviving FOLDER.
40
+ *
41
+ * The fallback is deliberately narrow: `COALESCE(n.id, …)` prefers the node whenever it exists, so
42
+ * for every other event nothing changes at all. It is not "or the parent is allowed" — that would
43
+ * hand out every event in a readable folder about nodes inside it that are NOT readable.
44
+ *
45
+ * ⚠️ **The title comes from the metadata for the same reason** — there is no row to read it from —
46
+ * and events written before #645 carry no `parentId`, so they stay unreachable. That cannot be
47
+ * repaired: at a deleted row there is nothing left to look up where it hung.
48
+ *
32
49
  * ⚠️ **`ORDER BY` and the cursor comparison name BOTH columns, in the same direction.** Two events
33
50
  * in the same millisecond are the normal case inside a batch, not the exception; on `occurred_at`
34
51
  * alone the second one is skipped in silence. SQLite has no row-value comparison here, so the
35
52
  * tuple comparison is written out: "earlier timestamp, OR same timestamp and smaller id".
36
53
  *
37
- * ⚠️ **Both columns descend, and that is what lets the index carry this.** `audit_events_feed_idx`
38
- * is `(resource_type, occurred_at, id)` ascending; SQLite reads an index backwards only when every
39
- * sort column is reversed together. Reversing one would cost a sort over the whole table, and that
40
- * is invisible on the first pages and only hurts far down.
54
+ * ⚠️ **Both columns descend, and that is what lets an index carry this.** SQLite reads an index
55
+ * backwards only when every sort column is reversed together. Reversing one would cost a sort over
56
+ * the whole table, and that is invisible on the first pages and only hurts far down.
57
+ *
58
+ * ⚠️ **The index that carries it is `audit_events_time_idx`, NOT `audit_events_feed_idx`** (#763).
59
+ * The feed reads two kinds at once, and the older index leads with `resource_type`; under
60
+ * `IN ('node', 'flow')` — or, as written here, under two branches joined by `OR` — that column
61
+ * stops being an equality prefix, and the ordering falls off it. Measured before `0026` existed:
62
+ * `SEARCH e USING INDEX audit_events_feed_idx (resource_type=?)` followed by
63
+ * `USE TEMP B-TREE FOR ORDER BY`. The older index stays for `audit_list`, which names one kind.
64
+ *
65
+ * ⚠️ **The unary `+` in front of each `resource_type` is load-bearing, not a typo.** It tells SQLite
66
+ * the term may not drive an index. Without it the planner sees two indexable `OR` branches, picks
67
+ * `MULTI-INDEX OR` over the OLD index — one search per branch, both on `resource_type=?` — and buys
68
+ * the filtering back at the price of the ordering: `USE TEMP B-TREE FOR ORDER BY` returns, over the
69
+ * whole table. Measured both ways. Filtering on kind is cheap here (two values out of three);
70
+ * sorting the journal is not.
71
+ *
72
+ * ⚠️ **The two halves ask two DIFFERENT authorization questions, and neither may stand in for the
73
+ * other.** A node is visible when the node itself is in `allowed`; a flow when its FOLDER is, or
74
+ * when it carries a grant of its own — flows hang in the tree but have nothing beneath them, so
75
+ * `flowInSubtree` is the rule and `allowed.id = flow.id` would answer about a node that does not
76
+ * exist. Both halves fall back on the metadata `parentId` for the one event whose row is gone.
41
77
  */
78
+ const FLOW_PARENT = "COALESCE(flow.parent_id, json_extract(e.metadata_json, '$.parentId'))";
79
+ const NODE_PARENT = "COALESCE(n.id, json_extract(e.metadata_json, '$.parentId'))";
42
80
  export const feedPageQuery = `${subtreeCte}
43
- SELECT e.id, e.actor_id, e.action, e.resource_id, n.title AS node_title,
44
- n.parent_id AS node_parent_id, e.metadata_json, e.occurred_at
81
+ SELECT e.id, e.actor_id, e.action, e.resource_id,
82
+ COALESCE(n.title, flow.title, json_extract(e.metadata_json, '$.title')) AS resource_title,
83
+ COALESCE(n.parent_id, flow.parent_id, json_extract(e.metadata_json, '$.parentId'))
84
+ AS resource_parent_id,
85
+ e.metadata_json, e.occurred_at
45
86
  FROM audit_events e
46
- JOIN allowed ON allowed.id = e.resource_id
47
- JOIN nodes n ON n.id = e.resource_id
48
- WHERE e.resource_type = 'node'
49
- AND e.action IN (${ACTION_PLACEHOLDERS})
87
+ LEFT JOIN nodes n ON n.id = e.resource_id AND e.resource_type = 'node'
88
+ LEFT JOIN flows flow ON flow.id = e.resource_id AND e.resource_type = 'flow'
89
+ WHERE (
90
+ (
91
+ +e.resource_type = 'node'
92
+ AND e.action IN (${NODE_PLACEHOLDERS})
93
+ AND ${NODE_PARENT} IN (SELECT id FROM allowed)
94
+ )
95
+ OR (
96
+ +e.resource_type = 'flow'
97
+ AND e.action IN (${FLOW_PLACEHOLDERS})
98
+ AND ${flowInSubtreeOver(FLOW_PARENT)}
99
+ )
100
+ )
50
101
  AND (? IS NULL OR e.actor_id = ?)
51
102
  AND (
52
103
  ? IS NULL
@@ -68,19 +119,26 @@ export const feedPageQuery = `${subtreeCte}
68
119
  * and `UNION` ends the walk because the row repeats — a depth counter would make every row unique
69
120
  * and turn the same cycle into a query that never returns. The order is rebuilt in TypeScript from
70
121
  * `parent_id`, where a visited-set makes the cycle harmless.
122
+ *
123
+ * ⚠️ **The seeds are the PARENTS, not the nodes** (#645). Starting at the node and stepping up to
124
+ * its parent works only while the node exists, and the one event that most needs a trail is the one
125
+ * whose node is gone. Starting a level higher costs nothing for the ordinary case — the parent is
126
+ * where the walk went anyway — and it is the only anchor a deletion has left.
127
+ *
128
+ * There is no `seed` column: ids are unique, so one flat set of visible ancestors serves every card
129
+ * on the page, and each trail is rebuilt from its own `parentId` through that set.
71
130
  */
72
131
  export const feedAncestorsQuery = (seeds) => `${subtreeCte},
73
- ancestry(seed, id, parent_id, title) AS (
74
- SELECT child.id, parent.id, parent.parent_id, parent.title
75
- FROM nodes child
76
- JOIN nodes parent ON parent.id = child.parent_id
77
- WHERE child.id IN (${Array.from({ length: seeds }, () => "?").join(", ")})
132
+ ancestry(id, parent_id, title) AS (
133
+ SELECT parent.id, parent.parent_id, parent.title
134
+ FROM nodes parent
135
+ WHERE parent.id IN (${Array.from({ length: seeds }, () => "?").join(", ")})
78
136
  UNION
79
- SELECT a.seed, grandparent.id, grandparent.parent_id, grandparent.title
137
+ SELECT grandparent.id, grandparent.parent_id, grandparent.title
80
138
  FROM nodes grandparent
81
139
  JOIN ancestry a ON a.parent_id = grandparent.id
82
140
  )
83
- SELECT a.seed, a.id, a.parent_id, a.title
141
+ SELECT a.id, a.parent_id, a.title
84
142
  FROM ancestry a
85
143
  JOIN allowed ON allowed.id = a.id`;
86
144
  /**
@@ -115,36 +173,40 @@ export function createFeedRepository(deps) {
115
173
  return {
116
174
  async listEvents(actor, query) {
117
175
  const before = query.before ?? null;
176
+ // One reading of the clock for the whole call. Both guards measure grant expiry against it,
177
+ // and two `now()` calls could straddle the moment a grant runs out — a page whose flow half
178
+ // answers about a permission its node half no longer has.
179
+ const now = deps.now().toISOString();
118
180
  const page = await deps.db
119
181
  .prepare(feedPageQuery)
120
- .bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...ACTIONS, query.actor, query.actor, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.id, query.limit)
182
+ .bind(...subtreeBindings(actor, "read", now), ...NODE_ACTIONS, ...FLOW_ACTIONS, ...flowInSubtreeBindings(actor, "read", now), query.actor, query.actor, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.id, query.limit)
121
183
  .all();
122
184
  const rows = page.results ?? [];
123
185
  if (rows.length === 0)
124
186
  return { events: [] };
125
187
  // One walk for the whole page rather than one per row. The set is at most the page size, and
126
188
  // it is deduplicated because a busy morning on one document is exactly the case the feed
127
- // shows unsummarised today.
128
- const nodeIds = [...new Set(rows.map((row) => row.resource_id))];
129
- const ancestors = await deps.db
130
- .prepare(feedAncestorsQuery(nodeIds.length))
131
- .bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...nodeIds)
132
- .all();
133
- const ancestorRows = ancestors.results ?? [];
134
- const parentOf = new Map(rows.map((row) => [row.resource_id, row.node_parent_id]));
135
- const trails = new Map(nodeIds.map((id) => [
136
- id,
137
- trailFor(parentOf.get(id) ?? null, ancestorRows.filter((row) => row.seed === id)),
138
- ]));
189
+ // shows unsummarised today. The trail is keyed by EVENT rather than by node: two events about
190
+ // one node share a trail, but a deleted node and a live one can carry the same parent.
191
+ const parentIds = [
192
+ ...new Set(rows.map((row) => row.resource_parent_id).filter((id) => id !== null)),
193
+ ];
194
+ const ancestorRows = parentIds.length === 0
195
+ ? []
196
+ : ((await deps.db
197
+ .prepare(feedAncestorsQuery(parentIds.length))
198
+ .bind(...subtreeBindings(actor, "read", now), ...parentIds)
199
+ .all()).results ?? []);
200
+ const trails = new Map(rows.map((row) => [row.id, trailFor(row.resource_parent_id, ancestorRows)]));
139
201
  const events = rows.map((row) => ({
140
202
  id: row.id,
141
203
  actorId: row.actor_id,
142
204
  // Narrowed at the door by the SQL `IN`, so the cast names what the query already
143
205
  // guarantees rather than trusting the column.
144
206
  action: row.action,
145
- nodeId: row.resource_id,
146
- nodeTitle: row.node_title,
147
- path: trails.get(row.resource_id) ?? [],
207
+ resourceId: row.resource_id,
208
+ resourceTitle: row.resource_title,
209
+ path: trails.get(row.id) ?? [],
148
210
  metadata: parseMetadata(row.metadata_json),
149
211
  occurredAt: row.occurred_at,
150
212
  }));
@@ -532,7 +532,8 @@ export function createFlowRepository(deps) {
532
532
  deps.db
533
533
  .prepare(`INSERT INTO audit_events (
534
534
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
535
- ) SELECT ?, ?, 'flows.purge', 'flow', f.id, json_object('title', f.title), ?
535
+ ) SELECT ?, ?, 'flows.purge', 'flow', f.id,
536
+ json_object('title', f.title, 'parentId', f.parent_id), ?
536
537
  FROM flows f WHERE f.id = ? AND f.archived_at IS NOT NULL`)
537
538
  .bind(input.auditId, input.actorId, input.occurredAt, input.flowId),
538
539
  // The runs are this flow's history: the steps hang off the runs, the runs off the flow.
@@ -106,5 +106,15 @@ export declare function flowVerbBindings(flowId: string, actor: GrantActor, verb
106
106
  * different verbs would produce a predicate that is neither, and it is the kind of mismatch nothing
107
107
  * fails on: the query still runs and quietly hands out the wrong list.
108
108
  */
109
+ /**
110
+ * ⚠️ The parent is a PARAMETER for exactly one caller, and giving it one was cheaper than the
111
+ * alternative. The feed reads events about flows that no longer exist: after `flows.purge` the row
112
+ * is gone, so `flow.parent_id`, `flow.owner_id` and every `flow_grants` row are gone with it, and
113
+ * the only anchor left is the `parentId` the purge wrote into its own metadata (#763, D73). A
114
+ * second, similar-looking predicate beside this one is the drift `destructive.md` warns about: two
115
+ * expressions with similar names are two different questions, and the one that goes stale is the
116
+ * copy.
117
+ */
118
+ export declare function flowInSubtreeOver(parentExpression: string): string;
109
119
  export declare const flowInSubtree: string;
110
120
  export declare function flowInSubtreeBindings(actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
@@ -233,12 +233,24 @@ export function flowVerbBindings(flowId, actor, verb, now) {
233
233
  * different verbs would produce a predicate that is neither, and it is the kind of mismatch nothing
234
234
  * fails on: the query still runs and quietly hands out the wrong list.
235
235
  */
236
- export const flowInSubtree = `(
236
+ /**
237
+ * ⚠️ The parent is a PARAMETER for exactly one caller, and giving it one was cheaper than the
238
+ * alternative. The feed reads events about flows that no longer exist: after `flows.purge` the row
239
+ * is gone, so `flow.parent_id`, `flow.owner_id` and every `flow_grants` row are gone with it, and
240
+ * the only anchor left is the `parentId` the purge wrote into its own metadata (#763, D73). A
241
+ * second, similar-looking predicate beside this one is the drift `destructive.md` warns about: two
242
+ * expressions with similar names are two different questions, and the one that goes stale is the
243
+ * copy.
244
+ */
245
+ export function flowInSubtreeOver(parentExpression) {
246
+ return `(
237
247
  ? = 1
238
248
  OR flow.owner_id = ?
239
- OR flow.parent_id IN (SELECT id FROM allowed)
249
+ OR ${parentExpression} IN (SELECT id FROM allowed)
240
250
  OR ${flowGrantExists("flow.id")}
241
251
  )`;
252
+ }
253
+ export const flowInSubtree = flowInSubtreeOver("flow.parent_id");
242
254
  export function flowInSubtreeBindings(actor, verb, now) {
243
255
  return [actor.isAdmin ? 1 : 0, actor.id, ...grantBindings(actor, verb, now)];
244
256
  }
@@ -904,11 +904,18 @@ export function createNodeRepository(deps) {
904
904
  // ⚠️ FIRST, and reading the title out of the row that falls at the end of this batch. Its
905
905
  // `WHERE EXISTS` is what makes it honest: no row, no entry — a purge that hit nothing does
906
906
  // not book one.
907
+ //
908
+ // ⚠️ **`parentId` travels beside the title, and it is what makes this row READABLE
909
+ // afterwards** (#645). Visibility everywhere else walks `nodes` from the row itself, and
910
+ // after this batch that row is gone — so until now the event was unreachable for everybody,
911
+ // admin included. The surviving FOLDER answers instead. It is written here rather than
912
+ // derived later for the same reason the title is: after the delete there is nothing left to
913
+ // look either of them up from (`destructive.md`).
907
914
  deps.db
908
915
  .prepare(`INSERT INTO audit_events (
909
916
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
910
917
  ) SELECT ?, ?, 'node.purge', 'node', n.id,
911
- json_patch(?, json_object('title', n.title, 'kind', n.kind)), ?
918
+ json_patch(?, json_object('title', n.title, 'kind', n.kind, 'parentId', n.parent_id)), ?
912
919
  FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL`)
913
920
  .bind(input.auditId, input.actorId, JSON.stringify(input.metadata), input.occurredAt, input.nodeId),
914
921
  deps.db
@@ -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",
@@ -0,0 +1,23 @@
1
+ -- The feed reads both kinds at once — nodes and flows — and `audit_events_feed_idx` cannot serve
2
+ -- that. It leads with `resource_type` because every reader until now named exactly one kind, so for
3
+ -- a query saying `resource_type IN ('node', 'flow')` that column is no longer an equality prefix but
4
+ -- an ordinary filter, and the ordering falls off the index.
5
+ --
6
+ -- ⚠️ Measured against the real `feedPageQuery` before this file existed, not assumed
7
+ -- (anchrd/intel#763):
8
+ --
9
+ -- SEARCH e USING INDEX audit_events_feed_idx (resource_type=?)
10
+ -- … | USE TEMP B-TREE FOR ORDER BY
11
+ --
12
+ -- That last line is a sorting pass over the whole table. It costs nothing on the first page and
13
+ -- everything far down — which is exactly where an endless scroll goes.
14
+ --
15
+ -- ⚠️ Both columns, in the order the cursor compares them, for the reason `0022` gives: two events
16
+ -- written in the same millisecond are the normal case inside one batch, and without the tie-break
17
+ -- on `id` a reader skips the second one with no error and no log.
18
+ --
19
+ -- ⚠️ This does NOT replace `audit_events_feed_idx`. That one still serves `audit_list`, which names
20
+ -- one kind and is the contract `anchrd/signals` builds on; dropping it would put a sorting pass into
21
+ -- the reader that has none today. Two readers, two shapes, two indexes.
22
+ CREATE INDEX audit_events_time_idx
23
+ ON audit_events(occurred_at, id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.35.0",
3
+ "version": "0.37.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.29.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",