@anchrd/intel-api 0.13.0 → 0.14.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.
@@ -1,4 +1,4 @@
1
- import { ArchivedBoardStatusId, BoardDefaultStatuses, BoardMaxTaskDepth, } from "@anchrd/intel-contract";
1
+ import { ArchivedBoardStatusId, BoardDefaultStatuses, BoardMaxTaskDepth, MaxBoardTaskOrderLength, } from "@anchrd/intel-contract";
2
2
  import { generateKeyBetween } from "fractional-indexing";
3
3
  import { IntelError } from "../../shared/intel-error/intel-error.js";
4
4
  /**
@@ -147,7 +147,9 @@ export function upgradeStoredBoard(parsed) {
147
147
  * first `board_task_update` against such a pair writes the SAME task into both entries. The stored
148
148
  * board is then two identical tasks rather than two different ones. That is a consequence of
149
149
  * addressing a board by task id at all (#285) and predates this rule; the reason the rule is at the
150
- * import is to stop the pair from existing, not to make it survivable.
150
+ * import is to stop the pair from existing, not to make it survivable. A board that already holds
151
+ * one is repaired by `repairTaskIds` below — asked for, never done on the quiet — and the refusal
152
+ * here names it, because "your file is wrong" without a way out is where anchrd/intel#341 started.
151
153
  *
152
154
  * So it is asked exactly where there is a caller to answer: the bundle import, the one door a board
153
155
  * document written elsewhere comes in through. Everything else mints task ids itself (`deps.id()`)
@@ -165,6 +167,85 @@ export function repeatedBoardId(document) {
165
167
  }
166
168
  return null;
167
169
  }
170
+ /**
171
+ * The two digits a key is extended with when `fractional-indexing` will not read its bounds
172
+ * (anchrd/intel#359).
173
+ *
174
+ * ⚠️ Both are chosen rather than convenient. `V` is the middle of the character class, so an
175
+ * extension leaves room on either side of itself, and it deliberately is not `0`: a fraction ending
176
+ * in zero is exactly what `generateKeyBetween` refuses to read afterwards. `0` is therefore used
177
+ * only where it is the one digit that still fits under an upper bound.
178
+ */
179
+ const SmallestOrderDigit = "0";
180
+ const MiddleOrderDigit = "V";
181
+ /**
182
+ * A key strictly between two stored ones, spelled with nothing but the character class
183
+ * (anchrd/intel#359).
184
+ *
185
+ * ⚠️ It is the second half of `orderBetween`, reached only when the library refused, and it rests
186
+ * on the one property `BoardTaskOrder` really guarantees and `byOrder` already sorts by: keys are
187
+ * compared as strings.
188
+ *
189
+ * Two cases, and they are the whole alphabet:
190
+ *
191
+ * - `below` shares no prefix with `above`. Then the two already differ at a digit `above` wins, and
192
+ * anything appended to `below` keeps losing at that same digit — so `below + "V"` is between them
193
+ * however long either of them is.
194
+ * - `below` IS a prefix of `above`. Then every key after `below` starts with `below`, and the
195
+ * smallest one that exists is `below + "0"`. If `above` is that key, the character class holds
196
+ * NOTHING in between — `"a0"` and `"a00"` is the pair this ticket is named after — and the answer
197
+ * is `null`, which the callers turn into a refusal rather than a card placed somewhere else.
198
+ */
199
+ function extendedOrder(below, above) {
200
+ if (above === null)
201
+ return `${below}${MiddleOrderDigit}`;
202
+ if (below >= above)
203
+ return null;
204
+ if (!above.startsWith(below))
205
+ return `${below}${MiddleOrderDigit}`;
206
+ return above === `${below}${SmallestOrderDigit}` ? null : `${below}${SmallestOrderDigit}`;
207
+ }
208
+ /**
209
+ * A key strictly between two stored ones, or `null` when no key exists there (anchrd/intel#359).
210
+ *
211
+ * ⚠️ This is where "who decides what a valid order key is" is answered, and the answer is
212
+ * `BoardTaskOrder` — the character class — and NOT `fractional-indexing`. The two disagreed, and
213
+ * the disagreement WAS the bug: a stored board may spell `"0"`, `"a00"`, `"zzz"` or `"A"`, the
214
+ * library reads none of them, and it says so with a bare `Error` rather than an `IntelError` — a
215
+ * `500 internal_error` on an ordinary `board_task_add`, with one task on the board and no drag
216
+ * involved.
217
+ *
218
+ * ⚠️ Narrowing the schema to what the library reads was the other way round, and it is the trap
219
+ * #311, #318 and #321 each walked into from a different side: that would be a rule on the STORED
220
+ * document, and a stored board body is parsed in FOUR places — `parseStoredBoard`, `indexing.ts`,
221
+ * `document-links.ts` and the bundle import. A board carrying one such key would stop being
222
+ * drawable, searchable, linkable and movable, all at once, over a value that is server-assigned and
223
+ * that no caller ever sent.
224
+ *
225
+ * ⚠️ So the library is ASKED and not trusted. It is the only thing that knows how to keep keys
226
+ * short and dense, which is what every healthy board lives on, and it stays the first answer. But
227
+ * when it refuses a bound, that is a statement about the bound and not about the board, and the
228
+ * fallback carries on with string comparison alone.
229
+ *
230
+ * ⚠️ `null` is an answer and not a failure, and it is the one thing callers may not paper over. It
231
+ * means the character class holds no key in that interval at all, so the only alternatives are
232
+ * refusing and sliding a card past another one in silence — and the second is the mistake the
233
+ * second review round of anchrd/intel#341 produced.
234
+ *
235
+ * ⚠️ The `catch` drops the library's message on purpose, and this is not the swallowed cause
236
+ * `.claude/review-rules.md` names: nothing here is being reported to anybody. `generateKeyBetween`
237
+ * throws to say "I do not read this bound", which is a QUESTION ANSWERED and the whole reason the
238
+ * second half exists. The refusals a caller actually sees are built below from `null`, a state this
239
+ * function decided, and they name which side is at fault and what to do about it.
240
+ */
241
+ function orderBetween(below, above) {
242
+ try {
243
+ return generateKeyBetween(below, above);
244
+ }
245
+ catch {
246
+ return extendedOrder(below ?? "", above);
247
+ }
248
+ }
168
249
  /**
169
250
  * Every board operation, applied to the document rather than to the file (#285).
170
251
  *
@@ -180,9 +261,14 @@ export function repeatedBoardId(document) {
180
261
  */
181
262
  export function createBoard(deps) {
182
263
  function byOrder(left, right) {
183
- // The id breaks a tie that cannot happen: two keys are distinct by construction. It is here so
184
- // the sort is total, because an unstable order would make two readers of the same board
185
- // disagree about which card comes first.
264
+ // ⚠️ The id breaks a tie that DOES happen, which is why it is not merely insurance
265
+ // (anchrd/intel#359). A board bundle written elsewhere may name one key twice, and a placement
266
+ // whose bounds are two neighbours the caller named but that are not next to each other can mint
267
+ // one that a card between them already holds — the second is anchrd/intel#380 and is not fixed
268
+ // here. What every placement taking its bounds from the nearest keys ACTUALLY there cannot do
269
+ // is create a repeat: such a key is strictly inside an interval no card occupies. The tie is
270
+ // broken at all because the sort has to be total — an unstable order would make two readers of
271
+ // the same board disagree about which card comes first.
186
272
  if (left.order === right.order)
187
273
  return left.id < right.id ? -1 : 1;
188
274
  return left.order < right.order ? -1 : 1;
@@ -349,6 +435,91 @@ export function createBoard(deps) {
349
435
  for (const id of stack)
350
436
  visit(id);
351
437
  }
438
+ /**
439
+ * The key a placement gets, or the refusal that names the way out (anchrd/intel#359).
440
+ *
441
+ * ⚠️ Everything that mints a key for a placement comes through here, so that the two answers
442
+ * `orderBetween` can give — a key, or "no key exists there" — are turned into an `IntelError`
443
+ * once rather than at every call site. What must never come out of it is a bare `Error`: that is
444
+ * `500 internal_error` at the surface, on a board whose keys somebody else wrote.
445
+ *
446
+ * ⚠️ The length is checked on BOTH paths, not only on the extension. `generateKeyBetween` happily
447
+ * answers a 64-character key with a 65-character one, and a key one over
448
+ * `MaxBoardTaskOrderLength` would be stored and then refused by the very next read
449
+ * (`board_unreadable`) — the whole board lost over one added card.
450
+ *
451
+ * ⚠️ The first refusal names the crowded neighbour by its ORDER KEY rather than by its id, the
452
+ * same way the repair's does: the key is what made it the neighbour, and `board_get` answers with
453
+ * both. The second cannot — a key that is already as long as one may be leaves no interval and so
454
+ * no single task to point at — so it names the move instead of the card.
455
+ */
456
+ function mintedKey(below, above) {
457
+ const key = orderBetween(below, above);
458
+ if (key === null) {
459
+ // ⚠️ Two sentences, because they are two different situations and only one of them can point
460
+ // at a card. Equal bounds mean the two neighbours ARE one place, and naming "the task whose
461
+ // order is a0" would name both of them; either one moving is what opens the place.
462
+ const reason = below === above
463
+ ? `The tasks named as neighbours both carry the order key “${below}”, so there is no place between them. Move either of them with board_task_move`
464
+ : `No order key fits ${below === null ? `before “${above}”` : `between “${below}” and “${above}”`}, so this task has nowhere to go. Move the task whose order is “${above}” with board_task_move`;
465
+ throw new IntelError(409, "board_task_order_unreadable", `${reason}, then place this one again.`);
466
+ }
467
+ if (key.length > MaxBoardTaskOrderLength) {
468
+ throw new IntelError(409, "board_task_order_unreadable", "The order key beside this place is already as long as an order key may be, so there is no room left after it. Move the neighbouring task with board_task_move, then place this one again.");
469
+ }
470
+ return key;
471
+ }
472
+ /**
473
+ * The next key that is really ABOVE the one at `index`, rather than the next task in the list.
474
+ *
475
+ * ⚠️ The difference only exists on a board whose keys came out of a file, and there it is the
476
+ * whole point: two cards under one key have no order between them — `BoardTaskOrder` says so and
477
+ * `byOrder` next door assumes it away — so "between them" is not a place, and a run of equal keys
478
+ * is ONE place rather than several. Taking `others[index + 1]` blindly hands `generateKeyBetween`
479
+ * a lower bound that is not below its upper, which is the bare `Error: >= ` this ticket is about.
480
+ *
481
+ * ⚠️ Skipping the run moves nothing: the new card lands after the whole run instead of inside it,
482
+ * and every card that was on the board keeps its key.
483
+ */
484
+ function nextOrderAbove(others, index) {
485
+ const below = others[index]?.order;
486
+ if (below === undefined)
487
+ return null;
488
+ return others.slice(index + 1).find((task) => task.order > below)?.order ?? null;
489
+ }
490
+ /**
491
+ * The mirror of `nextOrderAbove`: the nearest key really BELOW the one at `index`.
492
+ *
493
+ * ⚠️ It is what a placement that names only a SUCCESSOR has to stand on, and forgetting it was a
494
+ * bug of its own rather than a detail of anchrd/intel#359. A drop at the top of a column names no
495
+ * predecessor (`board-kanban.ts` sends `rest[landing - 1] ?? null`), and answering that with "no
496
+ * lower bound at all" hands `generateKeyBetween` the whole key space: `(null, "a1")` mints `"a0"`
497
+ * — a key the card below may already hold, in this column or another. Intel then writes two tasks
498
+ * under one order key on an ordinary drag, which is the very state anchrd/intel#341 and this
499
+ * ticket exist to answer.
500
+ *
501
+ * ⚠️ Without it the extension is worse than wrong, it is silent: `extendedOrder("", above)` sees
502
+ * `above.startsWith("")` and answers `"0"`, the smallest key the character class can spell, so a
503
+ * card dropped above one task lands above NONE of them and nothing says so.
504
+ *
505
+ * ⚠️ Against a run of equal keys it answers the key below the WHOLE run, so the new card lands
506
+ * before all of them — the exact mirror of what `nextOrderAbove` does upward, and for the same
507
+ * reason: cards sharing a key have no order between them, so the run is one place.
508
+ *
509
+ * ⚠️ It answers about the bound the caller did NOT name. Where they named both and the two are
510
+ * not next to each other, the bounds are their cards and a key between them can be one that a
511
+ * card in the gap already holds — anchrd/intel#380, not closed here.
512
+ */
513
+ function previousOrderBelow(others, index) {
514
+ const above = others[index]?.order;
515
+ if (above === undefined)
516
+ return null;
517
+ // `reverse` mutates, and the slice it mutates is this call's own copy.
518
+ return (others
519
+ .slice(0, index)
520
+ .reverse()
521
+ .find((task) => task.order < above)?.order ?? null);
522
+ }
352
523
  /**
353
524
  * The order key for a task's new place.
354
525
  *
@@ -378,16 +549,55 @@ export function createBoard(deps) {
378
549
  const column = others.filter((task) => task.status === placement.status && task.parentId === placement.parentId);
379
550
  const last = column.at(-1);
380
551
  if (!last)
381
- return generateKeyBetween(others.at(-1)?.order ?? null, null);
552
+ return mintedKey(others.at(-1)?.order ?? null, null);
382
553
  afterIndex = others.indexOf(last);
383
554
  }
384
- const lower = afterIndex === null ? null : (others[afterIndex]?.order ?? null);
555
+ // Each bound is the named neighbour where the caller gave one, and the nearest key really on
556
+ // the other side of the other neighbour where they did not. Reading an absent bound as "the end
557
+ // of the key space" is what let a drop at the top of a column mint a key another card holds.
558
+ const lower = afterIndex !== null
559
+ ? (others[afterIndex]?.order ?? null)
560
+ : beforeIndex !== null
561
+ ? previousOrderBelow(others, beforeIndex)
562
+ : null;
385
563
  const upper = beforeIndex !== null
386
564
  ? (others[beforeIndex]?.order ?? null)
387
565
  : afterIndex !== null
388
- ? (others[afterIndex + 1]?.order ?? null)
566
+ ? nextOrderAbove(others, afterIndex)
389
567
  : null;
390
- return generateKeyBetween(lower, upper);
568
+ return mintedKey(lower, upper);
569
+ }
570
+ /**
571
+ * A key strictly after `below` and strictly under `above`, for the repair (anchrd/intel#341).
572
+ *
573
+ * ⚠️ The same `orderBetween` the placements use, because there is one answer to "how is an order
574
+ * key made" and it should not be given twice — this only says it in the repair's own words. What
575
+ * differs is the sentence a caller reads: they came here from a refused import, not from a drag.
576
+ *
577
+ * ⚠️ Refused rather than written, in both directions. Over `MaxBoardTaskOrderLength` the key would
578
+ * be stored and then rejected by the very next read (`board_unreadable`); with no key in the
579
+ * interval at all it would take a silent slide past the task above to carry on. Either way the
580
+ * board keeps its pair and hears why.
581
+ *
582
+ * ⚠️ And the refusal says what to DO, for the same reason the refused import does: the crowded
583
+ * neighbour is usually one `board_task_move` from being out of the way, and the repair then goes
584
+ * through. Since anchrd/intel#359 that move is a real way out rather than a second 500 — it meets
585
+ * the same unreadable keys and answers them, which is what closed the one dead end this exit did
586
+ * not reach.
587
+ *
588
+ * ⚠️ Nothing checks the key against the ones the board already holds, and nothing needs to:
589
+ * `above` is the SMALLEST key above `below`, so every other key on the board is at or below
590
+ * `below` or at or above `above`, and `orderBetween` answers strictly inside that interval.
591
+ */
592
+ function keyAfter(below, above) {
593
+ const key = orderBetween(below, above);
594
+ if (key === null) {
595
+ throw new IntelError(409, "board_task_order_unreadable", `Nothing fits between the repeated task and the one ordered after it. Move the task whose order is “${above}” with board_task_move, then repair again.`);
596
+ }
597
+ if (key.length > MaxBoardTaskOrderLength) {
598
+ throw new IntelError(409, "board_task_order_unreadable", "The order key beside the repeated task is already as long as an order key may be, so a repair has nowhere to put the second entry");
599
+ }
600
+ return key;
391
601
  }
392
602
  function replaced(board, task) {
393
603
  return {
@@ -523,6 +733,94 @@ export function createBoard(deps) {
523
733
  });
524
734
  return { board: { statuses: board.statuses, tasks }, deleted: removed.size };
525
735
  },
736
+ /**
737
+ * The way out of a board that names one task id twice (anchrd/intel#341).
738
+ *
739
+ * ⚠️ Nobody chooses which of the two survives, because nothing is lost: both entries stay,
740
+ * whole, and only the id of the later one changes. That is the entire repair, and it is what
741
+ * lets it happen at all — #321 refused to fold such a pair on the READ precisely because a fold
742
+ * throws one of two real tasks away, and the next save of any kind writes that away for good.
743
+ * Here somebody asked, one field of one entry moves, and the answer says exactly which.
744
+ *
745
+ * ⚠️ The FIRST stored entry keeps the id, and that is not a coin toss. Every read here already
746
+ * means that one by it: `taskOf` is a `find`, so `updateTask`, `moveTask` and `deleteTask` have
747
+ * always taken the first entry's content as "the task with this id". Renumbering the first
748
+ * instead would hand the id to a task the server never meant by it, and every `parentId` and
749
+ * `dependsOn` on the board would silently start pointing at a different card.
750
+ *
751
+ * ⚠️ The UI's fold picks a different entry, and that is not a disagreement to repair.
752
+ * `orderedTasks` (packages/ui) folds AFTER sorting, so the card on screen is whichever of the
753
+ * pair would be drawn first — which can be the second stored one. That rule is about a DRAWING
754
+ * and #321 chose it so the card a reader clicks is the one `byId` answers with; this one is
755
+ * about what the id MEANS, and the two only differ for as long as the pair exists. After the
756
+ * repair every id is distinct, both cards are drawn, and their titles say which is which.
757
+ *
758
+ * ⚠️ `parentId` and `dependsOn` are left exactly as they stand, on both entries and on every
759
+ * other task — they are NOT copied onto the renumbered one. Two tasks cannot inherit one edge:
760
+ * a `parentId` is one id, so "both" is not a thing the document can say. What the pointers
761
+ * meant while the pair existed is what they mean now — the entry that kept the id — and the
762
+ * renumbered task therefore comes out with nothing hanging off it. That is visible on the
763
+ * board, and `board_task_move` moves a subtask under it in one call; inventing the edges here
764
+ * would be the silent change this repair exists to avoid.
765
+ *
766
+ * ⚠️ The `order` key is re-minted with the id, and it HAS to be. A duplicate is a copy, so both
767
+ * entries usually carry the same fractional index — harmless while they were one card, and a
768
+ * broken board the moment they are two: `BoardTaskOrder` says a shared key leaves two tasks with
769
+ * no defined order at all, and `byOrder` next door assumes keys are distinct by construction.
770
+ * Both cards are drawn after the repair, so a drop between them sends the pair as its two
771
+ * neighbours and `generateKeyBetween` refuses a lower bound that is not below its upper — an
772
+ * `Error: >= ` rather than an `IntelError`, which is a 500 on an ordinary drag. Re-minting is
773
+ * not content anybody gave up: `order` is server-assigned and a caller can never send one, and
774
+ * the key is minted where the entry already stood.
775
+ *
776
+ * ⚠️ It is minted against the next key STRICTLY above, not against the neighbour. A file that
777
+ * repeats an id can repeat an order key three times over, and anchoring on an equal key would
778
+ * make the way out throw exactly what it exists to prevent.
779
+ *
780
+ * ⚠️ Neither `requireParent` nor `requireDependencies` runs. Such a board arrived through a
781
+ * bundle written elsewhere and may break other rules too — a dependency on a task it does not
782
+ * hold, a chain deeper than `BoardMaxTaskDepth` — and refusing the repair over one of those
783
+ * would leave the board stuck for a reason the caller did not come about. Nothing here can
784
+ * create one of THOSE either: a fresh id has nothing pointing at it, so no cycle can close
785
+ * through the task that changed and no chain grows.
786
+ *
787
+ * ⚠️ There is no sibling for `statuses`, and none is needed: `configure` writes the whole list
788
+ * and `ConfigureBoardInput` has demanded distinct ids since #285, so one `board_configure` with
789
+ * the repeated id spelled differently repairs a duplicate column — with the caller choosing the
790
+ * label, which is a thing only they can know.
791
+ */
792
+ repairTaskIds(board) {
793
+ const seen = new Set();
794
+ const renumbered = [];
795
+ // Both grow as keys are minted: `generateKeyBetween` is deterministic, so a third entry under
796
+ // one id anchored on the same key as the second would be handed the second's key back. It
797
+ // anchors on the last one minted for that id instead, which is also what keeps the entries in
798
+ // the order the file listed them.
799
+ const orders = board.tasks.map((task) => task.order);
800
+ const lastMinted = new Map();
801
+ const tasks = board.tasks.map((task) => {
802
+ if (!seen.has(task.id)) {
803
+ seen.add(task.id);
804
+ return task;
805
+ }
806
+ const below = lastMinted.get(task.id) ?? task.order;
807
+ const above = orders.filter((order) => order > below).sort();
808
+ const order = keyAfter(below, above.at(0) ?? null);
809
+ orders.push(order);
810
+ lastMinted.set(task.id, order);
811
+ const renamed = { ...task, id: deps.id(), order };
812
+ seen.add(renamed.id);
813
+ renumbered.push({ previousId: task.id, task: renamed });
814
+ return renamed;
815
+ });
816
+ // Refused rather than answered with an empty list: a caller sent here by a refused import
817
+ // learns that this board is not the one holding the pair, and a board that needs nothing does
818
+ // not get a version, an audit event and a reindex for a document that did not change.
819
+ if (renumbered.length === 0) {
820
+ throw new IntelError(409, "board_task_ids_distinct", "This board names every task once, so there is nothing to repair");
821
+ }
822
+ return { board: { statuses: board.statuses, tasks }, renumbered };
823
+ },
526
824
  sorted,
527
825
  };
528
826
  }
@@ -27,5 +27,12 @@ export interface BoardOperations {
27
27
  board: BoardDocument;
28
28
  deleted: number;
29
29
  };
30
+ repairTaskIds(board: BoardDocument): {
31
+ board: BoardDocument;
32
+ renumbered: {
33
+ previousId: string;
34
+ task: BoardTask;
35
+ }[];
36
+ };
30
37
  sorted(tasks: readonly BoardTask[]): BoardTask[];
31
38
  }
@@ -689,6 +689,34 @@ export function createNodes(deps) {
689
689
  throw new IntelError(500, "board_unreadable", "The written task cannot be read back");
690
690
  return task;
691
691
  }
692
+ /**
693
+ * What the repair renumbered, read back out of the version it wrote (anchrd/intel#341).
694
+ *
695
+ * ⚠️ The pairs come from the audit metadata for the same reason the added task's id does: a
696
+ * replayed key has to answer with what the FIRST attempt actually did, and the document alone
697
+ * cannot say. After the write every id on that board is distinct and none of them says it is new.
698
+ *
699
+ * ⚠️ The metadata carries ids and no titles — audit is metadata, and a title is text somebody
700
+ * wrote (the same line `configureBoard` draws for its status labels). The tasks themselves are
701
+ * read out of the document, so the answer is the board's own truth rather than a copy made at
702
+ * write time.
703
+ */
704
+ function renumberedTasks(written) {
705
+ const recorded = written.metadata.renumbered;
706
+ if (!Array.isArray(recorded) || recorded.length === 0) {
707
+ throw new IntelError(500, "board_unreadable", "The repair did not record what it renumbered");
708
+ }
709
+ return recorded.map((entry) => {
710
+ const pair = entry;
711
+ const task = typeof pair.taskId === "string"
712
+ ? written.document.tasks.find((candidate) => candidate.id === pair.taskId)
713
+ : undefined;
714
+ if (typeof pair.previousId !== "string" || !task) {
715
+ throw new IntelError(500, "board_unreadable", "The repaired task cannot be read back");
716
+ }
717
+ return { previousId: pair.previousId, task };
718
+ });
719
+ }
692
720
  /**
693
721
  * What the grant just written does not cover: the documents the flows in this folder read that
694
722
  * the new principal still cannot.
@@ -884,11 +912,19 @@ export function createNodes(deps) {
884
912
  return (await deps.repository.can(actor, folder.id, "write")) ? "ok" : "forbidden";
885
913
  },
886
914
  async create(actor, input) {
887
- // The generic path can file an agent row too one without a definition or a principal so
888
- // the #190 gate stands here as well. Only where no runtime exists: with one, this path stays
889
- // exactly as it was, which is part of the same ticket.
890
- if (input.kind === "agent" && !deps.agentRuntimeAvailable()) {
891
- throw new IntelError(503, "agent_runtime_not_configured", "This installation has no agent runtime, so agents cannot be created");
915
+ // ⚠️ An agent is never filed through the generic path, whatever the installation looks like
916
+ // (#193). The row it produced was an agent by kind and nothing else: no definition, and no
917
+ // Gate Application so its first run failed with `agent_key_missing` in the runtime, and the
918
+ // repair was a second, easily forgotten step in Gate. `createAgent` makes both in one go and
919
+ // asks Gate BEFORE it writes anything, so a refusal leaves nothing behind.
920
+ //
921
+ // The refusal is here rather than on each surface for the reason every rule in this file is:
922
+ // `POST /nodes` and the `node_create` MCP tool are two doors into one service, and a check at
923
+ // a door is a check somebody adds a third door past. #190 put the runtime gate here and left
924
+ // this case open on purpose — the ticket demanded "exactly as today" where a runtime exists.
925
+ // This is that case, closed.
926
+ if (input.kind === "agent") {
927
+ throw new IntelError(400, "agent_needs_agent_endpoint", "An agent is created with its definition and its principal together — use the agent endpoint (POST /nodes/agents, or the agent_create tool) rather than the generic node create");
892
928
  }
893
929
  const existingId = await deps.repository.findIdempotentNode(actor.id, "node.create", input.idempotencyKey);
894
930
  if (existingId)
@@ -1510,6 +1546,38 @@ export function createNodes(deps) {
1510
1546
  }
1511
1547
  return { node: written.node, version: written.version, deleted };
1512
1548
  },
1549
+ /**
1550
+ * The way out of a board that names one task id twice (anchrd/intel#341).
1551
+ *
1552
+ * ⚠️ It goes through `applyToBoard` like the other five, so it is one version against the board
1553
+ * as it stands, it replays on its key, and it loses a race the same way. What it does NOT do is
1554
+ * name a task — see `repairTaskIds` in `board.ts` for why the pair is exactly what cannot be
1555
+ * named, and for which entry keeps the id.
1556
+ *
1557
+ * ⚠️ No `checkBoardTargets`: nothing about `references` or `assignee` moves, so there is no
1558
+ * fresh claim on the tree to check. Re-checking the ones already stored would refuse the repair
1559
+ * over a reference somebody lost access to since the import — a board stuck for a second reason
1560
+ * on the way out of the first.
1561
+ */
1562
+ async repairBoardTaskIds(actor, input) {
1563
+ const written = await applyToBoard(actor, input, "node.board_task_repair", (document) => {
1564
+ const repaired = board.repairTaskIds(document);
1565
+ return {
1566
+ document: repaired.board,
1567
+ metadata: {
1568
+ renumbered: repaired.renumbered.map((entry) => ({
1569
+ previousId: entry.previousId,
1570
+ taskId: entry.task.id,
1571
+ })),
1572
+ },
1573
+ };
1574
+ });
1575
+ return {
1576
+ node: written.node,
1577
+ version: written.version,
1578
+ renumbered: renumberedTasks(written),
1579
+ };
1580
+ },
1513
1581
  async listVersions(actor, nodeId) {
1514
1582
  await requireVisible(actor, nodeId);
1515
1583
  return { items: await deps.repository.listVersions(nodeId) };
@@ -1625,6 +1693,26 @@ export function createNodes(deps) {
1625
1693
  }
1626
1694
  throw new IntelError(409, "update_conflict", "This node was changed by another editor");
1627
1695
  }
1696
+ /**
1697
+ * ⚠️ Both directions, through the same queue every save goes through (anchrd/intel#348). The
1698
+ * pass reads the node's state and does the matching thing: an archived node has its vectors
1699
+ * taken out of the index, a restored one is embedded again from a record that was emptied when
1700
+ * it went. Two calls to one door rather than a purge written out here, because Vectorize is a
1701
+ * second system and a call into it can fail — the queue is the only thing in this repository
1702
+ * that comes back for it, and a deletion nobody retries is a deletion that quietly did not
1703
+ * happen.
1704
+ *
1705
+ * ⚠️ The full-text half is deliberately NOT emptied on the way in, and the asymmetry is the
1706
+ * point rather than an oversight: an FTS row costs storage and is already invisible (every
1707
+ * read joins `nodes` and drops what is archived), while a vector costs a place in a candidate
1708
+ * list Vectorize caps at 100 for the whole installation. The archived board pays with
1709
+ * somebody else's search results.
1710
+ *
1711
+ * A replayed archive never reaches this line — it returned above, on the idempotency key — so
1712
+ * repeating the same call does not ask the index to forget the same names twice.
1713
+ */
1714
+ if (updated.currentVersionId)
1715
+ await deps.indexing.enqueue(updated.currentVersionId);
1628
1716
  return updated;
1629
1717
  },
1630
1718
  async listGrants(actor, resourceId) {
@@ -1772,18 +1860,50 @@ export function createNodes(deps) {
1772
1860
  if (!deps.semantic)
1773
1861
  return { items: lexical.slice(0, input.limit) };
1774
1862
  try {
1775
- // ⚠️ The scope narrows the vector hits afterwards, in the same D1 statement that already
1776
- // re-checks the ACL — so the candidate set has to be wide enough for that cut to leave
1777
- // something. A folder holding a dozen documents inside a tree of thousands is not reached by
1778
- // the fan-out an unscoped search gets away with, and a starved scope looks like an empty
1779
- // folder. 100 is what the port clamps to, so a scoped search asks for all there is.
1780
- const candidates = input.scopeId === undefined ? Math.min(100, input.limit * 4) : 100;
1863
+ /**
1864
+ * Every candidate Vectorize will give for one query, scoped or not (anchrd/intel#348).
1865
+ *
1866
+ * ⚠️ A candidate is a CARD since anchrd/intel#301, not a node, so a busy board can take a
1867
+ * large share of these places and push other nodes out before this code ever sees them
1868
+ * the fold below cannot repair that, it runs on what came back. `limit * 4` was written when
1869
+ * a board was one vector and forty candidates were forty nodes; against one vector per card
1870
+ * it is a list a single board fills on its own. Asking for the ceiling is the one widening
1871
+ * available. Vectorize charges the query and not the depth, so that side is free; the price
1872
+ * is on the D1 side, where `hydrateVisibleCitations` batches 40 pairs per statement and the
1873
+ * ordinary unscoped search therefore goes from one statement to as many as three. Nothing
1874
+ * about the ceiling itself moves: a `limit` of 25 and every scoped search reached 100 before.
1875
+ *
1876
+ * ⚠️ It is a widening and not a fix, and the reason it is not is written down in
1877
+ * `packages/api/CLAUDE.md`: bounding the fan-out per NODE means filtering on a `nodeId`
1878
+ * metadata index, and Cloudflare only puts a vector into such an index when it is upserted
1879
+ * AFTER the index was created — so it would cost every installation a full re-embed of its
1880
+ * tree, plus an operational step no deployment has taken. That is anchrd/intel#356.
1881
+ *
1882
+ * ⚠️ 100 is the port's own clamp too, and it is Vectorize's documented ceiling for a query
1883
+ * that returns neither values nor metadata (50 for one that does). The scoped case has asked
1884
+ * for it since #126, because a scope cuts the candidates AFTERWARDS, in the D1 statement
1885
+ * that re-checks the ACL — a folder of a dozen documents inside a tree of thousands is not
1886
+ * reached by a narrow fan-out, and a starved scope looks like an empty folder.
1887
+ */
1888
+ const candidates = 100;
1781
1889
  const hits = await deps.semantic.search(input.query, candidates);
1782
- const semanticScores = new Map();
1890
+ /**
1891
+ * The best-scoring chunk of each node, and its score (anchrd/intel#301).
1892
+ *
1893
+ * ⚠️ The best, not the sum and not the first. A board answers once per card whose vector
1894
+ * matched, and a citation names a node — so a board of three hundred mediocre cards must not
1895
+ * out-rank one document that actually answers, and the passage the reader is shown has to be
1896
+ * the card that scored, not the one that happened to come back first.
1897
+ */
1898
+ const best = new Map();
1783
1899
  for (const hit of hits) {
1784
- semanticScores.set(hit.nodeId, Math.max(semanticScores.get(hit.nodeId) ?? 0, hit.score));
1900
+ const current = best.get(hit.nodeId);
1901
+ if (current === undefined || hit.score > current.score) {
1902
+ best.set(hit.nodeId, { chunkKey: hit.chunkKey, score: hit.score });
1903
+ }
1785
1904
  }
1786
- const semantic = await deps.repository.hydrateVisibleCitations(actor, [...semanticScores.keys()], input.scopeId);
1905
+ const semanticScores = new Map([...best].map(([nodeId, winner]) => [nodeId, winner.score]));
1906
+ const semantic = await deps.repository.hydrateVisibleCitations(actor, [...best].map(([nodeId, winner]) => ({ nodeId, chunkKey: winner.chunkKey })), input.scopeId);
1787
1907
  return {
1788
1908
  items: mergeSearchResults(lexical, semantic, semanticScores, input.limit),
1789
1909
  };
@@ -1796,6 +1916,18 @@ export function createNodes(deps) {
1796
1916
  if (actor.isAdmin !== true) {
1797
1917
  throw new IntelError(403, "reindex_forbidden", "Reindex permission is required");
1798
1918
  }
1919
+ /**
1920
+ * ⚠️ First, and before a single version is enqueued: the record of what the vector index
1921
+ * holds (anchrd/intel#301). Since #301 an indexing pass skips a chunk whose fingerprint has
1922
+ * not moved, which is what keeps a board of three hundred cards from costing three hundred
1923
+ * embeddings per save — and it would equally make `reindex` skip everything, so a vector
1924
+ * index that had been emptied would stay empty while every version was dutifully requeued.
1925
+ * That is the one failure this call exists to prevent, and it is a silent one: the answer
1926
+ * would be a search that finds less and a `queued` count that says all is well.
1927
+ *
1928
+ * The full-text half needs no equivalent because its rows are overwritten, never skipped.
1929
+ */
1930
+ await deps.repository.invalidateVectors();
1799
1931
  let queued = 0;
1800
1932
  let after = null;
1801
1933
  for (;;) {