@anchrd/intel-api 0.34.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.
@@ -192,6 +192,10 @@ export default {
192
192
  id: ulid,
193
193
  now,
194
194
  folderAccess: async (actor, folderId) => await nodes.folderAccess(actor, folderId),
195
+ // ⚠️ Straight to the repository, past the node service: this one question is asked WITHOUT the
196
+ // visibility predicate on purpose (#733), and a service method taking an actor would invite
197
+ // somebody to hand one in.
198
+ parentArchivedAt: async (folderId) => await nodeRepository.parentArchivedAt(folderId),
195
199
  nodeChildren: async (actor, folderId, limit) => await nodes.childrenBounded(actor, { parentId: folderId, limit }),
196
200
  // The tree's own visibility lookup, unchanged on the way through: the relation graph, the
197
201
  // requirements list and every tree link of every run read this one answer, so none of
@@ -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
  },
@@ -739,8 +739,19 @@ export function createFlowRepository(deps) {
739
739
  async archiveFlow(input) {
740
740
  try {
741
741
  await deps.db.batch([
742
+ /**
743
+ * ⚠️ **`archived_with` is CLEARED here, in both directions** (D71, #733, found in review
744
+ * of #738). A flow that travels through this door travels alone, so it belongs to no
745
+ * group: archiving it on its own must not leave it looking like part of one, and a row
746
+ * that still pointed at a group it no longer belongs to would come back with that group's
747
+ * next restore.
748
+ *
749
+ * The group itself is written by ONE place, the cascade in `archiveNode`. Minting an
750
+ * operation here as well would be a second writer for one column, and it would have no
751
+ * reader: for a restore, "no group" and "somebody else's group" are the same answer.
752
+ */
742
753
  deps.db
743
- .prepare(`UPDATE flows SET archived_at = ?, updated_at = ?
754
+ .prepare(`UPDATE flows SET archived_at = ?, archived_with = NULL, updated_at = ?
744
755
  WHERE id = ? AND updated_at = ?`)
745
756
  .bind(input.archivedAt, input.updatedAt, input.flowId, input.baseUpdatedAt),
746
757
  deps.db
@@ -176,22 +176,6 @@ 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
- },
195
179
  async listVisibleBounded(actor, input) {
196
180
  const result = await deps.db
197
181
  .prepare(visibleChildren(true))
@@ -580,13 +564,114 @@ export function createNodeRepository(deps) {
580
564
  return "conflict";
581
565
  },
582
566
  async archiveNode(input) {
567
+ const archiving = input.archivedAt !== null;
568
+ /**
569
+ * ⚠️ **The operation a node falls with, read BEFORE anything moves** (D71, #733).
570
+ *
571
+ * Archiving takes the whole subtree, so a restore needs to know which nodes fell TOGETHER —
572
+ * anything already archived before keeps its own older operation and stays archived. On the
573
+ * way in that is the new id; on the way back it is the one the root already carries, and it
574
+ * has to be read here because the first statement below clears it.
575
+ *
576
+ * ⚠️ `NULL` is not a group. A node archived before this migration carries `NULL`, and
577
+ * restoring it must bring back that node alone — matching on `NULL` would revive every node
578
+ * ever archived before the migration ran.
579
+ */
580
+ const group = archiving
581
+ ? input.operationId
582
+ : ((await deps.db
583
+ .prepare("SELECT archived_with FROM nodes WHERE id = ?")
584
+ .bind(input.nodeId)
585
+ .first())?.archived_with ?? null);
586
+ const subtree = `WITH RECURSIVE tree(id) AS (
587
+ SELECT id FROM nodes WHERE id = ?
588
+ UNION SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
589
+ )`;
590
+ /**
591
+ * ⚠️ **Every cascading statement carries the same guard, not just the first** (#457). A
592
+ * `D1.batch` is one transaction, but a statement matching zero rows is not an error — so
593
+ * without `EXISTS` on the root the children would fall while the root stayed put, taken out
594
+ * by the `updated_at` guard on statement one, and the caller would be told about a conflict
595
+ * that had already half happened.
596
+ */
597
+ const rootMoved = archiving
598
+ ? "EXISTS (SELECT 1 FROM nodes WHERE id = ? AND archived_at = ? AND archived_with = ?)"
599
+ : "EXISTS (SELECT 1 FROM nodes WHERE id = ? AND archived_at IS NULL)";
600
+ const cascade = archiving
601
+ ? [
602
+ // The subtree, minus what was already archived: those keep their own operation.
603
+ deps.db
604
+ .prepare(`${subtree}
605
+ UPDATE nodes SET archived_at = ?, archived_with = ?, updated_at = ?
606
+ WHERE id IN (SELECT id FROM tree) AND id != ? AND archived_at IS NULL
607
+ AND ${rootMoved}`)
608
+ .bind(input.nodeId, input.archivedAt, input.operationId, input.updatedAt, input.nodeId, input.nodeId, input.archivedAt, input.operationId),
609
+ // Flows hang in the same tree by `parent_id` and carry their own `archived_at`.
610
+ deps.db
611
+ .prepare(`${subtree}
612
+ UPDATE flows SET archived_at = ?, archived_with = ?, updated_at = ?
613
+ WHERE parent_id IN (SELECT id FROM tree) AND archived_at IS NULL
614
+ AND ${rootMoved}`)
615
+ .bind(input.nodeId, input.archivedAt, input.operationId, input.updatedAt, input.nodeId, input.archivedAt, input.operationId),
616
+ ]
617
+ : group === null
618
+ ? []
619
+ : [
620
+ /**
621
+ * ⚠️ **The group AND the subtree, not the group alone** (found in review of #738).
622
+ * Matching on the operation by itself made restoring a leaf bring back its former
623
+ * parent and every sibling that fell with it: one card pulled out of a board's
624
+ * archive column put the whole card tree back. The walk starts at the node being
625
+ * restored, so a leaf restores itself and a folder restores what hangs under it.
626
+ */
627
+ deps.db
628
+ .prepare(`${subtree}
629
+ UPDATE nodes SET archived_at = NULL, archived_with = NULL, updated_at = ?
630
+ WHERE id IN (SELECT id FROM tree) AND id != ? AND archived_with = ?
631
+ AND ${rootMoved}`)
632
+ .bind(input.nodeId, input.updatedAt, input.nodeId, group, input.nodeId),
633
+ deps.db
634
+ .prepare(`${subtree}
635
+ UPDATE flows SET archived_at = NULL, archived_with = NULL, updated_at = ?
636
+ WHERE parent_id IN (SELECT id FROM tree) AND archived_with = ?
637
+ AND ${rootMoved}`)
638
+ .bind(input.nodeId, input.updatedAt, group, input.nodeId),
639
+ ];
640
+ // How many rows this operation moves besides the root, read before it moves them: the journal
641
+ // line is written inside the same batch and cannot count what it is doing.
642
+ const cascadeSize = archiving
643
+ ? ((await deps.db
644
+ .prepare(`WITH RECURSIVE tree(id) AS (
645
+ SELECT id FROM nodes WHERE id = ?
646
+ UNION SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
647
+ )
648
+ SELECT (SELECT COUNT(*) FROM nodes WHERE id IN (SELECT id FROM tree) AND id != ?
649
+ AND archived_at IS NULL)
650
+ + (SELECT COUNT(*) FROM flows WHERE parent_id IN (SELECT id FROM tree)
651
+ AND archived_at IS NULL) AS total`)
652
+ .bind(input.nodeId, input.nodeId)
653
+ .first())?.total ?? 0)
654
+ : group === null
655
+ ? 0
656
+ : ((await deps.db
657
+ .prepare(`WITH RECURSIVE tree(id) AS (
658
+ SELECT id FROM nodes WHERE id = ?
659
+ UNION SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
660
+ )
661
+ SELECT (SELECT COUNT(*) FROM nodes WHERE id IN (SELECT id FROM tree) AND id != ?
662
+ AND archived_with = ?)
663
+ + (SELECT COUNT(*) FROM flows WHERE parent_id IN (SELECT id FROM tree)
664
+ AND archived_with = ?) AS total`)
665
+ .bind(input.nodeId, input.nodeId, group, group)
666
+ .first())?.total ?? 0);
583
667
  try {
584
668
  await deps.db.batch([
585
669
  deps.db
586
670
  .prepare(`UPDATE nodes
587
- SET archived_at = ?, updated_at = ?
671
+ SET archived_at = ?, archived_with = ?, updated_at = ?
588
672
  WHERE id = ? AND updated_at = ?`)
589
- .bind(input.archivedAt, input.updatedAt, input.nodeId, input.baseUpdatedAt),
673
+ .bind(input.archivedAt, archiving ? input.operationId : null, input.updatedAt, input.nodeId, input.baseUpdatedAt),
674
+ ...cascade,
590
675
  deps.db
591
676
  .prepare(`INSERT INTO idempotency_keys (
592
677
  actor_id, operation, idempotency_key, resource_id, created_at
@@ -605,7 +690,22 @@ export function createNodeRepository(deps) {
605
690
  WHERE actor_id = ? AND operation = 'node.archive'
606
691
  AND idempotency_key = ? AND resource_id = ?
607
692
  )`)
608
- .bind(input.auditId, input.actorId, input.nodeId, JSON.stringify({ archived: input.archivedAt !== null }), input.updatedAt, input.actorId, input.idempotencyKey, input.nodeId),
693
+ .bind(input.auditId, input.actorId, input.nodeId,
694
+ /**
695
+ * ⚠️ **The operation and the SIZE of what moved** (D71, #733). One `node.archive` now
696
+ * moves a whole subtree, and the change journal (#747) reads this table: without the
697
+ * count, archiving a folder makes N documents disappear from every list behind a
698
+ * single line that names only the folder.
699
+ *
700
+ * ⚠️ What this row does NOT carry is WHICH nodes fell. After a restore the column is
701
+ * cleared, so that list is gone; if it is ever needed, it needs its own row per node
702
+ * and that is a decision, not a detail.
703
+ */
704
+ JSON.stringify({
705
+ archived: input.archivedAt !== null,
706
+ operation: group,
707
+ cascaded: cascadeSize,
708
+ }), input.updatedAt, input.actorId, input.idempotencyKey, input.nodeId),
609
709
  ]);
610
710
  }
611
711
  catch (error) {
@@ -630,6 +730,38 @@ export function createNodeRepository(deps) {
630
730
  .first();
631
731
  return row ? mapNode(row) : "conflict";
632
732
  },
733
+ async parentArchivedAt(nodeId) {
734
+ // ⚠️ **No visibility predicate, and that is the whole point of this query** (found in review
735
+ // of #738). A grant can sit on a single node (ADR-0004 §2), so whoever may restore a child may
736
+ // well not see the folder above it. Asked through `getVisible`, that folder answers `null`,
737
+ // the guard reads "not archived", and a live node ends up under an archived one — where the
738
+ // purge takes it unnamed. It is the same lesson `countLiveChildren` carried before D71
739
+ // replaced it, and the same mistake was made again in its place.
740
+ //
741
+ // It leaks one timestamp about a node the caller cannot open. The alternative leaks the node
742
+ // itself, permanently.
743
+ const row = await deps.db
744
+ .prepare("SELECT archived_at, kind FROM nodes WHERE id = ?")
745
+ .bind(nodeId)
746
+ .first();
747
+ return row ? { archivedAt: row.archived_at, kind: row.kind } : null;
748
+ },
749
+ async versionIdsInSubtree(nodeId) {
750
+ // ⚠️ No visibility predicate and no archived filter, for the reason `hasAnyChild` gives: this
751
+ // answers what the INDEX has to be told about, not what somebody may look at. A node whose
752
+ // vectors stay behind pays for itself out of everybody else's search results (#348).
753
+ const rows = await deps.db
754
+ .prepare(`WITH RECURSIVE tree(id) AS (
755
+ SELECT id FROM nodes WHERE id = ?
756
+ UNION SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
757
+ )
758
+ SELECT n.current_version_id AS version_id FROM nodes n
759
+ JOIN tree ON tree.id = n.id
760
+ WHERE n.current_version_id IS NOT NULL`)
761
+ .bind(nodeId)
762
+ .all();
763
+ return (rows.results ?? []).map((row) => row.version_id);
764
+ },
633
765
  async hasAnyChild(nodeId) {
634
766
  // ⚠️ Without a visibility predicate and without an archived filter, both on purpose: this
635
767
  // answers whether the row can be deleted at all, not what somebody may look at (#457).
@@ -675,12 +807,12 @@ export function createNodeRepository(deps) {
675
807
  UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
676
808
  )`;
677
809
  const nodes = await deps.db
678
- .prepare(`${tree} SELECT n.id, n.kind FROM nodes n JOIN tree ON tree.id = n.id`)
810
+ .prepare(`${tree} SELECT n.id, n.kind, n.title FROM nodes n JOIN tree ON tree.id = n.id`)
679
811
  .bind(nodeId)
680
812
  .all();
681
813
  const nodeIds = (nodes.results ?? []).map((row) => row.id);
682
814
  const flows = await deps.db
683
- .prepare(`${tree} SELECT f.id FROM flows f WHERE f.parent_id IN (SELECT id FROM tree)`)
815
+ .prepare(`${tree} SELECT f.id, f.title FROM flows f WHERE f.parent_id IN (SELECT id FROM tree)`)
684
816
  .bind(nodeId)
685
817
  .all();
686
818
  const keys = await deps.db
@@ -702,6 +834,26 @@ export function createNodeRepository(deps) {
702
834
  counts[row.kind] = (counts[row.kind] ?? 0) + 1;
703
835
  return {
704
836
  nodeIds,
837
+ // The names the preview shows, capped there rather than here: a caller that wants the whole
838
+ // tree has `nodeIds`, and the cap belongs where the sentence is written (#733).
839
+ /**
840
+ * ⚠️ **Flows FIRST, and the order is the finding rather than a preference** (second review
841
+ * of #738). The preview names the first fifty, so with flows appended a folder holding
842
+ * fifty documents and one flow named every document and hid the flow — the one thing the
843
+ * cascade had to be widened for, and the one a reader is least likely to expect in there.
844
+ */
845
+ named: [
846
+ ...(flows.results ?? []).map((row) => ({
847
+ id: row.id,
848
+ kind: "flow",
849
+ title: row.title,
850
+ })),
851
+ ...(nodes.results ?? []).map((row) => ({
852
+ id: row.id,
853
+ kind: row.kind,
854
+ title: row.title,
855
+ })),
856
+ ],
705
857
  flowIds: (flows.results ?? []).map((row) => row.id),
706
858
  contentKeys: [...new Set((keys.results ?? []).map((row) => row.content_key))],
707
859
  vectorKeys: nodeIds.map((id) => ({ nodeId: id, keys: grouped.get(id) ?? [] })),
@@ -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);
@@ -216,7 +301,10 @@ export function createBoards(deps) {
216
301
  * design exists against.
217
302
  *
218
303
  * `nodes.archive` is the same door `node_archive` uses — one path, one audit row, one set of
219
- * refusals (a card with live subtasks is still refused, #669).
304
+ * refusals. ⚠️ Since D71 (#733) that door CASCADES: dropping a card with subtasks on the
305
+ * archive column takes them with it, where #669 used to refuse the drop. Pulling the card back
306
+ * out brings exactly those back, and pulling a SUBTASK out while its parent card is archived
307
+ * is refused instead — a live card under an archived one is what the purge reaches.
220
308
  */
221
309
  const intoArchive = input.status === ARCHIVE_COLUMN_ID;
222
310
  const outOfArchive = input.status !== undefined && !intoArchive && row.archivedAt !== null;
@@ -305,7 +393,7 @@ export function createBoards(deps) {
305
393
  // read gives, so a board one may not see stays indistinguishable from one that is not there.
306
394
  await columnsOrRefuse(actor, input.boardId, "read");
307
395
  if (!deps.directory)
308
- return { items: [] };
396
+ return { items: [], more: 0 };
309
397
  /**
310
398
  * ⚠️ ENFORCED HERE, not only described. The contract's `query` says a single character
311
399
  * answers empty, and until this line that sentence was true only because gate happens to
@@ -314,19 +402,31 @@ export function createBoards(deps) {
314
402
  * It is also the cheaper answer: a letter that can only ever produce a listing does not
315
403
  * need to travel to gate first to be refused there.
316
404
  */
317
- if (input.query.trim().length < MINIMUM_QUERY)
318
- return { items: [] };
319
- 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);
320
421
  if (hits.length === 0)
321
- return { items: [] };
422
+ return { items: [], more: 0 };
322
423
  const access = await deps.boards.effectiveAccess(input.boardId);
323
424
  // A grant to the organization means everybody reaches the board, so there is nothing left to
324
425
  // narrow — and narrowing anyway would produce an empty picker on exactly the boards that are
325
426
  // shared with everyone, which is most of them.
326
427
  if (access.organizationWide)
327
- return { items: hits };
328
- const reaches = new Set([...access.ownerIds, ...access.principalIds]);
329
- 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 };
330
430
  },
331
431
  /**
332
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[];
@@ -1446,6 +1446,23 @@ export function createFlows(deps) {
1446
1446
  throw new IntelError(404, "flow_not_found", "Flow was not found");
1447
1447
  return flow;
1448
1448
  }
1449
+ /**
1450
+ * ⚠️ **A flow cannot be restored on its own into an archived folder** (D71, #733, found in
1451
+ * review of #738). The same refusal `nodes.archive` carries, and for the same reason: a live
1452
+ * flow under an archived folder is reached by a purge of that folder, which checks
1453
+ * `archived_at` on its root alone and walks the whole subtree.
1454
+ */
1455
+ if (!input.archived && current.parentId !== null) {
1456
+ // ⚠️ Asked WITHOUT the visibility predicate, and that is the difference between a guard and
1457
+ // a decoration: a flow is reachable through `flow_grants` on its own, so its grantee may not
1458
+ // see the folder it is filed in. `folderAccess` answers `missing` for such a folder, the
1459
+ // guard would not fire, and a live flow would hang under an archived one — where the purge
1460
+ // of that folder takes it through `parent_id IN tree`.
1461
+ const folder = await deps.parentArchivedAt(current.parentId);
1462
+ if (folder?.archivedAt) {
1463
+ throw new IntelError(409, "parent_archived", "The folder this flow is filed in is archived, so it cannot be restored on its own. Restore the folder first, and this comes back with it.");
1464
+ }
1465
+ }
1449
1466
  const updatedAt = deps.now().toISOString();
1450
1467
  const updated = await deps.repository.archiveFlow({
1451
1468
  flowId: current.id,
@@ -224,6 +224,10 @@ export interface FlowDeps {
224
224
  id(): string;
225
225
  now(): Date;
226
226
  folderAccess(actor: FlowActor, folderId: string): Promise<FolderAccess>;
227
+ parentArchivedAt(folderId: string): Promise<{
228
+ archivedAt: string | null;
229
+ kind: string;
230
+ } | null>;
227
231
  /**
228
232
  * The children of one folder of the shared tree as this actor may see them, without their bodies.
229
233
  * `null` is the root. The node service answers, for the same reason `folderAccess` does.
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",
@@ -640,7 +640,7 @@ export async function handleMcp(request, deps) {
640
640
  */
641
641
  server.registerTool("node_purge_preview", {
642
642
  title: "Preview a permanent deletion",
643
- description: "Count what deleting one ARCHIVED node for good would take with it: every item in its subtree, documents and flows alike, and how many documents elsewhere link to it. It changes nothing. Ask before node_purge — afterwards there is no row left to count, and the links that break are not refused, only reported.",
643
+ description: "Ask before node_purge, afterwards there is no row left to count. Names and counts what deleting one ARCHIVED node for good would take with it: the nodes and flows in its subtree with their titles, capped at fifty with the total beside it, plus how many documents link to it from outside. Changes nothing.",
644
644
  inputSchema: PurgeNodePreviewInput,
645
645
  annotations: {
646
646
  title: "Preview a permanent deletion",
@@ -87,14 +87,14 @@ function decodeBase64(value) {
87
87
  * ⚠️ **Nothing may be filed under an ARCHIVED node**, and this is not tidiness — it is what keeps
88
88
  * the purge honest.
89
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.
90
+ * Archiving takes the whole subtree since D71 (#733), so an archived node holds nothing live at the
91
+ * moment it is archived. Without this rule it could GAIN one afterwards, and `purge` — which asks
92
+ * only whether the node itself is archived — walks the whole subtree and takes that child with it.
93
+ * It would disappear for good without ever being named in the confirmation.
94
94
  *
95
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.
96
+ * sentence was true only as long as an archived node could never gain a live child, and until #679
97
+ * and D71 nothing made it true: the guard covered tasks alone, and archiving covered one row.
98
98
  */
99
99
  /**
100
100
  * The board a node would be filed on: the board itself, or the board of the task it hangs under
@@ -120,17 +120,19 @@ async function boardFor(deps, actor, parent) {
120
120
  return null;
121
121
  return (await deps.boardOfTask?.(actor, parent.id)) ?? null;
122
122
  }
123
+ // How many of the nodes a purge would take are named in the preview. High enough that an ordinary
124
+ // folder is listed in full, low enough that a tree nobody can render is not built (#733).
125
+ const PURGE_PREVIEW_NAMES = 50;
123
126
  function refuseArchivedParent(parent) {
124
127
  // ⚠️ EVERY kind, not just tasks (#679). It was narrowed to tasks in #677 because widening it out
125
128
  // of a board ticket would have changed the whole tree with no test outside `boards.int.ts`; the
126
129
  // hole it left is older than that ticket and is what this one closes.
127
130
  //
128
- // ⚠️ **This asks the DIRECT parent only, and the hole one level up stays open** (#733). Archiving a
129
- // folder that still holds something live is allowed for every kind but `task`, so `Aussen`
130
- // archived over a live `Innen` still takes anything filed under `Innen` when it is purged:
131
- // `inspectPurgeTree` walks the whole subtree and `purge` checks `archived_at` on the root alone.
132
- // Closing that needs a decision (refuse the archive, cascade it, refuse the purge, or name the
133
- // live nodes in the preview) and #733 carries it with the count of what already exists.
131
+ // ⚠️ **The DIRECT parent is enough since D71 (#733), and only since then.** Archiving cascades, so
132
+ // an archived node holds nothing live at the moment it is archived, and a restore under an
133
+ // archived parent is refused: there is no live node under an archived one for something to be
134
+ // filed beneath. Before that, this guard closed one level while `Aussen` archived over a live
135
+ // `Innen` still handed anything filed under `Innen` to the purge.
134
136
  if (parent === null || parent.archivedAt === null)
135
137
  return;
136
138
  throw new IntelError(409, "parent_archived", `This ${parent.kind} is archived, so nothing new can be filed under it. Restore it first.`);
@@ -516,6 +518,19 @@ export function createNodes(deps) {
516
518
  return {
517
519
  inboundLinks: await deps.repository.countInboundLinks(node.id),
518
520
  totalItems: tree.nodeIds.length + tree.flowIds.length,
521
+ /**
522
+ * ⚠️ **Named, not just counted** (D71, #733), and the cap is stated rather than silent:
523
+ * `totalItems` keeps counting past it, so a surface can always say "and 40 more". Whoever
524
+ * confirms a purge is deciding about things, and "82 items" is not a thing.
525
+ *
526
+ * The node itself is in here too. It is the one the caller named, so it is the one they can
527
+ * check the list against.
528
+ */
529
+ items: tree.named.slice(0, PURGE_PREVIEW_NAMES).map((entry) => ({
530
+ id: entry.id,
531
+ kind: entry.kind,
532
+ title: entry.title,
533
+ })),
519
534
  };
520
535
  },
521
536
  /**
@@ -552,6 +567,10 @@ export function createNodes(deps) {
552
567
  // its ACLs, rather than being reimplemented on the flow side.
553
568
  async folderAccess(actor, folderId) {
554
569
  const folder = await deps.repository.getVisible(actor, folderId);
570
+ // ⚠️ An archived folder answers `missing` rather than a state of its own: filing into one is
571
+ // refused exactly like filing into one that is not there. The question "is the folder above
572
+ // archived" is asked by `parentArchivedAt` instead, because it must not run through the
573
+ // visibility predicate at all (#733).
555
574
  if (!folder || folder.archivedAt)
556
575
  return "missing";
557
576
  if (folder.kind !== "folder")
@@ -1033,24 +1052,50 @@ export function createNodes(deps) {
1033
1052
  if (replayedId)
1034
1053
  return await requireVisible(actor, replayedId);
1035
1054
  /**
1036
- * ⚠️ **A task with live subtasks is refused, not archived with them** (#669).
1037
- * `destructive.md`: *verweigern schlägt kaskadieren* a cascade turns one confirmed click
1038
- * into an unknown number of disappearances, and the question that was asked ("archive this
1039
- * card") cannot then name what actually vanishes.
1055
+ * ⚠️ **Archiving takes the whole subtree, flows included** (D71, #733), and the refusal that
1056
+ * used to stand here is gone with it. `task_has_subtasks` (#669) refused a card with live
1057
+ * subtasks, on the reasoning that a cascade turns one confirmed click into an unknown number
1058
+ * of disappearances.
1059
+ *
1060
+ * That reasoning holds for `purge` and not for this call. The two steps are not symmetric:
1061
+ * archiving is REVERSIBLE, so cascading it costs nothing that cannot be undone, while `purge`
1062
+ * has cascaded over the subtree since #492 and cannot be undone at all. With the cascade on
1063
+ * this side, nothing live sits under an archived node any more — which is precisely what
1064
+ * makes the purge honest, and what `refuseArchivedParent` below keeps true afterwards.
1065
+ *
1066
+ * ⚠️ The counterpart is the restore, and it is the part that can quietly do too much or too
1067
+ * little. It brings back what fell with THIS operation **and hangs under the node being
1068
+ * restored** — a child archived earlier keeps its own operation and stays archived, and
1069
+ * restoring a leaf does not drag its whole former tree back with it.
1070
+ */
1071
+ /**
1072
+ * ⚠️ **Archiving something that is already archived changes nothing, deliberately** (found in
1073
+ * review of #738). Without this it mints a NEW operation on the root while the cascade skips
1074
+ * every child (`archived_at IS NULL`), so the children keep the old one — and a later restore
1075
+ * hands back an empty folder while its contents stay archived, with nothing saying so.
1040
1076
  *
1041
- * ⚠️ It is refused only ON THE WAY IN. Restoring a parent must stay possible, or a card
1042
- * archived together with its children by an older path could never come back.
1077
+ * The likeliest way there is not misuse but a retry after a timeout with a fresh idempotency
1078
+ * key: the key does not catch it, and the operation is re-minted.
1079
+ */
1080
+ if (input.archived && current.archivedAt !== null)
1081
+ return current;
1082
+ /**
1083
+ * ⚠️ **A restore under an archived parent is refused, and this is the other half of
1084
+ * `refuseArchivedParent`** (found in review of #738). Without it the cascade closes nothing:
1085
+ * archive a child alone, archive the folder over it, restore the child, and a LIVE node hangs
1086
+ * under an archived one again — where `purge` reaches it, because it checks `archived_at` on
1087
+ * the root alone and walks the whole subtree.
1043
1088
  *
1044
- * The other half `purge` needs no rule of its own, but ONLY together with
1045
- * `refuseArchivedParent` below: only an archived node may be purged, a parent cannot reach
1046
- * that state while a child is live, and nothing new may be filed under it afterwards. Take
1047
- * any one of the three away and a live card can be deleted for good without being named —
1048
- * which is what the review of #669 found in the first version of this sentence.
1089
+ * The refusal names the way out rather than the rule: whoever wants this child back wants the
1090
+ * folder back first, and then the child comes with it.
1049
1091
  */
1050
- if (input.archived && current.kind === "task") {
1051
- const children = await deps.repository.countLiveChildren(current.id);
1052
- if (children > 0) {
1053
- 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.`);
1092
+ if (!input.archived && current.parentId !== null) {
1093
+ // ⚠️ Read WITHOUT the visibility predicate, see `parentArchivedAt`: asked through
1094
+ // `getVisible` this guard misses exactly the actor it exists for, the one holding a grant on
1095
+ // the child alone.
1096
+ const parent = await deps.repository.parentArchivedAt(current.parentId);
1097
+ if (parent?.archivedAt) {
1098
+ throw new IntelError(409, "parent_archived", `This ${parent.kind} is archived, so nothing under it can be restored on its own. Restore it first, and this comes back with it.`);
1054
1099
  }
1055
1100
  }
1056
1101
  const updatedAt = deps.now().toISOString();
@@ -1058,6 +1103,7 @@ export function createNodes(deps) {
1058
1103
  nodeId: current.id,
1059
1104
  baseUpdatedAt: input.baseUpdatedAt,
1060
1105
  archivedAt: input.archived ? updatedAt : null,
1106
+ operationId: deps.id(),
1061
1107
  updatedAt,
1062
1108
  actorId: actor.id,
1063
1109
  idempotencyKey: input.idempotencyKey,
@@ -1083,9 +1129,21 @@ export function createNodes(deps) {
1083
1129
  *
1084
1130
  * A replayed archive never reaches this line — it returned above, on the idempotency key — so
1085
1131
  * repeating the same call does not ask the index to forget the same names twice.
1132
+ *
1133
+ * ⚠️ **The whole subtree, not the root** (D71, #733, found in review of #738). Since archiving
1134
+ * cascades, a folder with fifty documents takes fifty nodes out of sight — and every one of
1135
+ * them would keep its place in the candidate list if only the root were enqueued. The pass
1136
+ * reads each node's state, so a node that was already archived costs one no-op rather than a
1137
+ * wrong answer.
1138
+ *
1139
+ * ⚠️ It is one enqueue per versioned node in the subtree, sequentially, and each one is a
1140
+ * subrequest. A tree large enough runs into the Workers limit AFTER the batch has committed:
1141
+ * archived tree, error to the caller, index half followed. That is #754, and it needs
1142
+ * `sendBatch` or a sweeper rather than a cap here — a cap would be the silent kind.
1086
1143
  */
1087
- if (updated.currentVersionId)
1088
- await deps.indexing.enqueue(updated.currentVersionId);
1144
+ for (const versionId of await deps.repository.versionIdsInSubtree(updated.id)) {
1145
+ await deps.indexing.enqueue(versionId);
1146
+ }
1089
1147
  return updated;
1090
1148
  },
1091
1149
  /**
@@ -55,23 +55,6 @@ 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>;
75
58
  listVisibleSubtree(actor: Actor, rootId: string | null): Promise<SubtreeNode[]>;
76
59
  can(actor: Actor, nodeId: string, verb: ResourceVerb): Promise<boolean>;
77
60
  findIdempotentNode(actorId: string, operation: "node.create" | "node.save" | "node.append" | "node.update" | "node.archive" | "node.share" | "node.revoke" | SnapshotOperation, idempotencyKey: string): Promise<string | null>;
@@ -96,16 +79,27 @@ export interface NodeRepository {
96
79
  nodeId: string;
97
80
  baseUpdatedAt: string;
98
81
  archivedAt: string | null;
82
+ operationId: string;
99
83
  updatedAt: string;
100
84
  actorId: string;
101
85
  idempotencyKey: string;
102
86
  auditId: string;
103
87
  }): Promise<"conflict" | Node>;
88
+ versionIdsInSubtree(nodeId: string): Promise<string[]>;
89
+ parentArchivedAt(nodeId: string): Promise<{
90
+ archivedAt: string | null;
91
+ kind: string;
92
+ } | null>;
104
93
  hasAnyChild(nodeId: string): Promise<boolean>;
105
94
  listVectorKeys(nodeId: string): Promise<string[]>;
106
95
  countInboundLinks(nodeId: string): Promise<number>;
107
96
  inspectPurgeTree(nodeId: string): Promise<{
108
97
  nodeIds: string[];
98
+ named: Array<{
99
+ id: string;
100
+ kind: string;
101
+ title: string;
102
+ }>;
109
103
  flowIds: string[];
110
104
  contentKeys: string[];
111
105
  vectorKeys: Array<{
@@ -356,6 +350,11 @@ export interface NodeService {
356
350
  }): Promise<{
357
351
  inboundLinks: number;
358
352
  totalItems: number;
353
+ items: Array<{
354
+ id: string;
355
+ kind: NodeKind;
356
+ title: string;
357
+ }>;
359
358
  }>;
360
359
  listGrants(actor: Actor, resourceId: string): Promise<ResourceGrantList>;
361
360
  listEffectiveAccess(actor: Actor, resourceId: string): Promise<import("@anchrd/intel-contract/share").ResourceAccessList>;
@@ -0,0 +1,29 @@
1
+ -- Archiving takes the whole subtree, and this column is what lets it come back (D71, #733).
2
+ --
3
+ -- ⚠️ **The two steps were the wrong way round.** `purge` cascaded over the subtree since #492 while
4
+ -- `archive` touched a single row, so a folder archived over live children handed those children to
5
+ -- the one step that cannot be undone. Cascading the REVERSIBLE step instead closes the whole chain:
6
+ -- nothing live sits under an archived node any more, so the purge can keep cascading without ever
7
+ -- reaching something alive.
8
+ --
9
+ -- What this column holds is the OPERATION a node fell with, not a flag. A restore brings back
10
+ -- exactly the nodes that fell together, and a child that was already archived before keeps its own
11
+ -- older operation id — and stays archived.
12
+ --
13
+ -- ⚠️ **Existing data keeps `NULL` here**, and that is not the same as "fell alone": it means
14
+ -- "archived before this migration, and nobody recorded what it fell with". A restore therefore
15
+ -- treats `NULL` as itself only, never as a group — otherwise restoring one old node would revive
16
+ -- every node ever archived before today.
17
+ ALTER TABLE nodes ADD COLUMN archived_with TEXT;
18
+
19
+ -- The restore reads this column for one node and then finds its siblings by it. Without the index
20
+ -- that is a scan of the whole table on every restore of a large folder.
21
+ CREATE INDEX IF NOT EXISTS nodes_archived_with ON nodes (archived_with) WHERE archived_with IS NOT NULL;
22
+
23
+ -- ⚠️ **Flows fall with the tree, so they need the same column.** They hang in the folder tree by
24
+ -- `parent_id` and carry their own `archived_at`, and `inspectPurgeTree` collects them by that same
25
+ -- parent. A cascade that took only `nodes` would leave exactly one kind of live thing under an
26
+ -- archived folder — the kind this repository's own flows are filed as.
27
+ ALTER TABLE flows ADD COLUMN archived_with TEXT;
28
+
29
+ CREATE INDEX IF NOT EXISTS flows_archived_with ON flows (archived_with) WHERE archived_with IS NOT NULL;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.34.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.26.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",