@anchrd/intel-api 0.34.0 → 0.35.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
@@ -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) ?? [] })),
@@ -216,7 +216,10 @@ export function createBoards(deps) {
216
216
  * design exists against.
217
217
  *
218
218
  * `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).
219
+ * refusals. ⚠️ Since D71 (#733) that door CASCADES: dropping a card with subtasks on the
220
+ * archive column takes them with it, where #669 used to refuse the drop. Pulling the card back
221
+ * out brings exactly those back, and pulling a SUBTASK out while its parent card is archived
222
+ * is refused instead — a live card under an archived one is what the purge reaches.
220
223
  */
221
224
  const intoArchive = input.status === ARCHIVE_COLUMN_ID;
222
225
  const outOfArchive = input.status !== undefined && !intoArchive && row.archivedAt !== null;
@@ -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
@@ -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.35.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.27.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",