@anchrd/intel-api 0.40.0 → 0.41.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.
@@ -10,6 +10,7 @@ import { createFlows } from "../../flows/flows.js";
10
10
  import { createIndexing, PermanentIndexingError } from "../../indexing/indexing.js";
11
11
  import { createIntel } from "../../intel/intel.js";
12
12
  import { createNodes } from "../../nodes/nodes.js";
13
+ import { createPrompts } from "../../prompts/prompts.js";
13
14
  import { bearer } from "../../shared/gate-authorization/gate-authorization.js";
14
15
  import { IntelError } from "../../shared/intel-error/intel-error.js";
15
16
  import { sha256Hex } from "../../shared/sha256/sha256.js";
@@ -22,6 +23,7 @@ import { createFeedRepository } from "../db/db-feed.js";
22
23
  import { createFlowRepository } from "../db/db-flows.js";
23
24
  import { createNodeIndexRepository } from "../db/db-indexing.js";
24
25
  import { createOAuthClientStore } from "../db/db-oauth.js";
26
+ import { createPromptRepository } from "../db/db-prompts.js";
25
27
  import { createDocumentConverter } from "../document-converter/document-converter.js";
26
28
  import { createFlowRuntime } from "../flow-runtime/flow-runtime.js";
27
29
  import { createIndexQueue, IndexMessage } from "../index-queue/index-queue.js";
@@ -265,6 +267,20 @@ export default {
265
267
  tools,
266
268
  audit: createAudit({ audit: auditRepository }),
267
269
  feed: createFeed({ feed: feedRepository }),
270
+ /**
271
+ * The slash-command catalogue (#775). It reads its own rows through its own repository and
272
+ * borrows exactly one thing from the node service: reading one document, with the
273
+ * authorization walk that service already performs. A second reader into R2 here would be a
274
+ * second place for that ACL to be applied, and the one that gets it wrong is the one nobody
275
+ * looks at.
276
+ */
277
+ prompts: createPrompts({
278
+ repository: createPromptRepository({ db: env.DB, now }),
279
+ // ⚠️ Handed on whole, and nothing is decided here. Turning a stored version into readable
280
+ // text is business behaviour, and this shell maps bindings — it does not get to know what a
281
+ // BlockNote payload is.
282
+ readDocument: async (actor, nodeId) => await nodes.get(actor, nodeId),
283
+ }),
268
284
  boards: createBoards({
269
285
  boards: boardRepository,
270
286
  nodes,
@@ -2,6 +2,7 @@ import { Flow, FlowGraph, FlowVersion, } from "@anchrd/intel-contract/flow";
2
2
  import { FlowRun } from "@anchrd/intel-contract/flow-run";
3
3
  import { calleeIds, treeLinkKinds } from "../../flows/flows.js";
4
4
  import { flowCallable, flowCallableBindings, flowInSubtree, flowInSubtreeBindings, flowVerbBindings, flowVerbQuery, grantColumns, grantInForce, mapGrant, principalColumns, readableOrRunnableCte, subtreeBindings, subtreeCte, } from "./db-grants.js";
5
+ import { promptNameHolder } from "./db-prompts.js";
5
6
  const flowColumnNames = [
6
7
  "id",
7
8
  "parent_id",
@@ -13,6 +14,7 @@ const flowColumnNames = [
13
14
  "created_at",
14
15
  "updated_at",
15
16
  "archived_at",
17
+ "prompt_name",
16
18
  ];
17
19
  const flowColumns = flowColumnNames.join(", ");
18
20
  const runColumnNames = [
@@ -53,6 +55,7 @@ function mapFlow(row) {
53
55
  createdAt: row.created_at,
54
56
  updatedAt: row.updated_at,
55
57
  archivedAt: row.archived_at,
58
+ promptName: row.prompt_name,
56
59
  });
57
60
  }
58
61
  function mapVersion(row) {
@@ -904,23 +907,61 @@ export function createFlowRepository(deps) {
904
907
  .first();
905
908
  return stored ? "saved" : "conflict";
906
909
  },
910
+ async promptNameHolder(actor, name) {
911
+ // One question, one implementation, shared with the node repository — see `db-prompts.ts`.
912
+ return await promptNameHolder(deps, actor, name);
913
+ },
914
+ /**
915
+ * ⚠️ **Two statement forms, not one with a conditional value** (#775). Publishing without
916
+ * mentioning a prompt name must leave the name alone; `SET prompt_name = ?` with the current
917
+ * value read back beforehand would work until two publishes race, and then the second one
918
+ * writes a name the first had just replaced. The form that does not mention the column cannot
919
+ * do that, and the form that does carries its guard.
920
+ *
921
+ * ⚠️ **The guard is on all three statements** (`destructive.md`, `anchrd/intel#457`). A
922
+ * statement matching zero rows is not an error, so an idempotency row and an audit row written
923
+ * without the same condition would book a publication that never happened — and the caller's
924
+ * retry would then be answered out of the replay table.
925
+ */
907
926
  async publish(input) {
927
+ const setsName = input.promptName !== undefined;
928
+ const nameAssignment = setsName ? ", prompt_name = ?" : "";
929
+ const nameBinding = setsName ? [input.promptName] : [];
930
+ // ⚠️ `other.id <> ?` lets a flow keep the name it already holds: republishing an offered flow
931
+ // sends the same name again, and without the exclusion it would collide with itself.
932
+ const nameGuard = setsName
933
+ ? `AND (
934
+ ? IS NULL
935
+ OR (
936
+ NOT EXISTS (SELECT 1 FROM nodes holder WHERE holder.prompt_name = ?)
937
+ AND NOT EXISTS (
938
+ SELECT 1 FROM flows other WHERE other.prompt_name = ? AND other.id <> ?
939
+ )
940
+ )
941
+ )`
942
+ : "";
943
+ const nameGuardBindings = setsName
944
+ ? [input.promptName, input.promptName, input.promptName, input.flowId]
945
+ : [];
946
+ const nameEcho = setsName ? "AND prompt_name IS ?" : "";
947
+ const nameEchoBinding = setsName ? [input.promptName] : [];
908
948
  try {
909
949
  await deps.db.batch([
910
950
  deps.db
911
- .prepare(`UPDATE flows SET published_version_id = ?, updated_at = ?
951
+ .prepare(`UPDATE flows SET published_version_id = ?${nameAssignment}, updated_at = ?
912
952
  WHERE id = ? AND EXISTS (
913
953
  SELECT 1 FROM flow_versions WHERE id = ? AND flow_id = ?
914
- )`)
915
- .bind(input.versionId, input.occurredAt, input.flowId, input.versionId, input.flowId),
954
+ )
955
+ ${nameGuard}`)
956
+ .bind(input.versionId, ...nameBinding, input.occurredAt, input.flowId, input.versionId, input.flowId, ...nameGuardBindings),
916
957
  deps.db
917
958
  .prepare(`INSERT INTO idempotency_keys (
918
959
  actor_id, operation, idempotency_key, resource_id, created_at
919
960
  ) SELECT ?, 'flows.publish', ?, ?, ?
920
961
  WHERE EXISTS (
921
- SELECT 1 FROM flows WHERE id = ? AND published_version_id = ?
962
+ SELECT 1 FROM flows WHERE id = ? AND published_version_id = ? ${nameEcho}
922
963
  )`)
923
- .bind(input.actorId, input.idempotencyKey, input.versionId, input.occurredAt, input.flowId, input.versionId),
964
+ .bind(input.actorId, input.idempotencyKey, input.versionId, input.occurredAt, input.flowId, input.versionId, ...nameEchoBinding),
924
965
  deps.db
925
966
  .prepare(`INSERT INTO audit_events (
926
967
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
@@ -937,7 +978,22 @@ export function createFlowRepository(deps) {
937
978
  if (!replayed)
938
979
  throw error;
939
980
  }
940
- return ((await this.findIdempotent(input.actorId, "flows.publish", input.idempotencyKey)) !== null);
981
+ const published = (await this.findIdempotent(input.actorId, "flows.publish", input.idempotencyKey)) !== null;
982
+ // ⚠️ Which of the two refusals it was, asked only after the write has failed — the same order
983
+ // `updateNode` uses, and for the same reason: deciding first and writing unguarded is the
984
+ // shape that loses the race the guard exists for.
985
+ if (!published && setsName && input.promptName !== null) {
986
+ const taken = await deps.db
987
+ .prepare(`SELECT 1 AS found FROM nodes WHERE prompt_name = ?
988
+ UNION ALL
989
+ SELECT 1 AS found FROM flows WHERE prompt_name = ? AND id <> ?
990
+ LIMIT 1`)
991
+ .bind(input.promptName, input.promptName, input.flowId)
992
+ .first();
993
+ if (taken?.found === 1)
994
+ return "prompt_name_taken";
995
+ }
996
+ return published;
941
997
  },
942
998
  // The mirror of `publish`, with the same shape of guards: the idempotency row is written only
943
999
  // if the UPDATE actually withdrew something, so revoking what was never published leaves no key
@@ -0,0 +1,46 @@
1
+ import type { Actor } from "../../nodes/nodes.types.js";
2
+ import type { PromptNameHolder, PromptRepository } from "../../prompts/prompts.types.js";
3
+ import type { D1Database } from "./db.types.js";
4
+ /**
5
+ * The documents this actor may read that are offered as a slash command (#775).
6
+ *
7
+ * ⚠️ **Two statements rather than one `UNION`, and the reason is the visibility walk.** A document
8
+ * is visible through `allowed`; a flow is visible through `flowInSubtree`, which is a different
9
+ * predicate over different tables and needs its own bindings in its own order. Welded into one
10
+ * statement the two would share a placeholder sequence that nobody can read back — and the failure
11
+ * that produces is a shifted binding, which does not throw: it answers the wrong list.
12
+ *
13
+ * ⚠️ **`archived_at IS NULL` is part of being offered.** Archiving is what "delete" means here, and
14
+ * a command that starts an archived document is a command onto something the tree no longer shows.
15
+ * The name stays taken while it is archived — a restore brings the command back rather than finding
16
+ * its name given away in the meantime.
17
+ *
18
+ * ⚠️ The `kind = 'document'` is redundant against the CHECK in `0028` and stays anyway: it is the
19
+ * one line that keeps this query honest if that CHECK is ever loosened, and it costs nothing on a
20
+ * partial index that only holds the offered rows.
21
+ */
22
+ export declare const offeredDocumentsQuery: string;
23
+ /**
24
+ * The published flows this actor may read that are offered as a slash command.
25
+ *
26
+ * ⚠️ **`published_version_id IS NOT NULL` is the switch, and it is the whole of it** (#775).
27
+ * Publishing is already the decision that a flow is for other people, so `flow_publish` sets the
28
+ * name and nothing else asks again. A flow that is withdrawn KEEPS its name and leaves this list:
29
+ * the name is what makes republishing it the same command rather than a new one, and a client that
30
+ * stored it is not left pointing at a command that has since become somebody else's.
31
+ *
32
+ * ⚠️ `read`, not `execute`, and that is not the obvious choice. A flow in the library — runnable by
33
+ * people who may not open it (ADR-0004 §2/§3) — is deliberately NOT listed: a catalogue entry
34
+ * carries a title and a description, and those are the readable half. Somebody who may only run it
35
+ * still can, through `flow_run_start` with the id they were given; what they do not get is a
36
+ * listing that names it.
37
+ */
38
+ export declare const offeredFlowsQuery: string;
39
+ export declare function promptNameHolder(deps: {
40
+ db: D1Database;
41
+ now(): Date;
42
+ }, actor: Actor, name: string): Promise<PromptNameHolder | null>;
43
+ export declare function createPromptRepository(deps: {
44
+ db: D1Database;
45
+ now(): Date;
46
+ }): PromptRepository;
@@ -0,0 +1,144 @@
1
+ import { flowInSubtree, flowInSubtreeBindings, subtreeBindings, subtreeCte } from "./db-grants.js";
2
+ /**
3
+ * The documents this actor may read that are offered as a slash command (#775).
4
+ *
5
+ * ⚠️ **Two statements rather than one `UNION`, and the reason is the visibility walk.** A document
6
+ * is visible through `allowed`; a flow is visible through `flowInSubtree`, which is a different
7
+ * predicate over different tables and needs its own bindings in its own order. Welded into one
8
+ * statement the two would share a placeholder sequence that nobody can read back — and the failure
9
+ * that produces is a shifted binding, which does not throw: it answers the wrong list.
10
+ *
11
+ * ⚠️ **`archived_at IS NULL` is part of being offered.** Archiving is what "delete" means here, and
12
+ * a command that starts an archived document is a command onto something the tree no longer shows.
13
+ * The name stays taken while it is archived — a restore brings the command back rather than finding
14
+ * its name given away in the meantime.
15
+ *
16
+ * ⚠️ The `kind = 'document'` is redundant against the CHECK in `0028` and stays anyway: it is the
17
+ * one line that keeps this query honest if that CHECK is ever loosened, and it costs nothing on a
18
+ * partial index that only holds the offered rows.
19
+ */
20
+ export const offeredDocumentsQuery = `${subtreeCte}
21
+ SELECT n.prompt_name, n.id, n.title, n.description
22
+ FROM nodes n
23
+ JOIN allowed ON allowed.id = n.id
24
+ WHERE n.prompt_name IS NOT NULL
25
+ AND n.kind = 'document'
26
+ AND n.archived_at IS NULL
27
+ ORDER BY n.prompt_name`;
28
+ /**
29
+ * The published flows this actor may read that are offered as a slash command.
30
+ *
31
+ * ⚠️ **`published_version_id IS NOT NULL` is the switch, and it is the whole of it** (#775).
32
+ * Publishing is already the decision that a flow is for other people, so `flow_publish` sets the
33
+ * name and nothing else asks again. A flow that is withdrawn KEEPS its name and leaves this list:
34
+ * the name is what makes republishing it the same command rather than a new one, and a client that
35
+ * stored it is not left pointing at a command that has since become somebody else's.
36
+ *
37
+ * ⚠️ `read`, not `execute`, and that is not the obvious choice. A flow in the library — runnable by
38
+ * people who may not open it (ADR-0004 §2/§3) — is deliberately NOT listed: a catalogue entry
39
+ * carries a title and a description, and those are the readable half. Somebody who may only run it
40
+ * still can, through `flow_run_start` with the id they were given; what they do not get is a
41
+ * listing that names it.
42
+ */
43
+ export const offeredFlowsQuery = `${subtreeCte}
44
+ SELECT flow.prompt_name, flow.id, flow.title, flow.description
45
+ FROM flows flow
46
+ WHERE flow.prompt_name IS NOT NULL
47
+ AND flow.published_version_id IS NOT NULL
48
+ AND flow.archived_at IS NULL
49
+ AND ${flowInSubtree}
50
+ ORDER BY flow.prompt_name`;
51
+ /**
52
+ * Who holds one name, asked of both tables at once, for the refusal a taken name gets.
53
+ *
54
+ * ⚠️ **Existence is answered to everybody; the TITLE only to those entitled to it.** The question
55
+ * "is this name free" has to be answered truthfully or the caller can do nothing — they are refused
56
+ * by an index they cannot see and told the node was changed by another editor. What must not travel
57
+ * is the title: naming the document that holds `payroll` tells somebody without access that a
58
+ * document by that title exists, and ADR-0004 §3 is explicit that a refusal must not become a way
59
+ * of reading the tree. The `CASE` below is where the two halves part.
60
+ *
61
+ * ⚠️ **One function, called from both repositories, rather than one query per repository.** A node
62
+ * write and a flow publish refuse for the same reason and have to say the same sentence; two
63
+ * spellings of this would drift, and the one that drifts is the one whose surface is used less.
64
+ * `destructive.md` calls it by name: two queries with similar names are two different questions.
65
+ */
66
+ const holderQuery = `${subtreeCte}
67
+ SELECT 'document' AS kind, CASE WHEN allowed.id IS NULL THEN NULL ELSE n.title END AS title
68
+ FROM nodes n
69
+ LEFT JOIN allowed ON allowed.id = n.id
70
+ WHERE n.prompt_name = ?
71
+ UNION ALL
72
+ SELECT 'flow' AS kind, CASE WHEN ${flowInSubtree} THEN flow.title ELSE NULL END AS title
73
+ FROM flows flow
74
+ WHERE flow.prompt_name = ?`;
75
+ export async function promptNameHolder(deps, actor, name) {
76
+ const now = deps.now().toISOString();
77
+ const row = await deps.db
78
+ .prepare(holderQuery)
79
+ .bind(...subtreeBindings(actor, "read", now), name, ...flowInSubtreeBindings(actor, "read", now), name)
80
+ .first();
81
+ return row === null ? null : { kind: row.kind, title: row.title };
82
+ }
83
+ function mapEntry(row, kind) {
84
+ return {
85
+ name: row.prompt_name,
86
+ kind,
87
+ targetId: row.id,
88
+ title: row.title,
89
+ description: row.description,
90
+ };
91
+ }
92
+ export function createPromptRepository(deps) {
93
+ return {
94
+ async listOffered(actor) {
95
+ const now = deps.now().toISOString();
96
+ /**
97
+ * ⚠️ **A half the actor has no capability for is not asked for at all** (#775, following
98
+ * #774). Running the statement and discarding its rows would be the same answer at the cost
99
+ * of a walk over the tree — and worse, it would put the decision in the mapping step, where
100
+ * the next reader of this function cannot see it.
101
+ *
102
+ * ⚠️ **Each statement carries its own kind, rather than the two being read back out of fixed
103
+ * positions.** With only `flows/read` the flow query is the ONLY element, so it arrives as
104
+ * result zero: a `const [documents, flows]` here puts the flows in a variable called
105
+ * `documents`, and the name is then a lie in one of the four branches. It answers correctly
106
+ * today and mis-binds the moment somebody adds a third conditional query — which is exactly
107
+ * the kind of edit this list invites.
108
+ */
109
+ const asked = [];
110
+ if (actor.canReadNodes) {
111
+ asked.push({
112
+ kind: "document",
113
+ statement: deps.db
114
+ .prepare(offeredDocumentsQuery)
115
+ .bind(...subtreeBindings(actor, "read", now)),
116
+ });
117
+ }
118
+ if (actor.canReadFlows) {
119
+ asked.push({
120
+ kind: "flow",
121
+ statement: deps.db
122
+ .prepare(offeredFlowsQuery)
123
+ .bind(...subtreeBindings(actor, "read", now), ...flowInSubtreeBindings(actor, "read", now)),
124
+ });
125
+ }
126
+ const results = await deps.db.batch(asked.map((each) => each.statement));
127
+ return (asked
128
+ .flatMap((each, index) => (results[index]?.results ?? []).map((row) => mapEntry(row, each.kind)))
129
+ // ⚠️ Sorted over the MERGED list rather than relying on the two `ORDER BY` clauses. Each
130
+ // statement orders its own half, and concatenating two ordered halves gives an unordered
131
+ // whole — `prompts/list` would then be alphabetical within documents and again within
132
+ // flows, which reads as "unsorted with a pattern" to anybody scrolling it.
133
+ .sort((left, right) => left.name.localeCompare(right.name)));
134
+ },
135
+ async findOffered(actor, name) {
136
+ // The catalogue is the list this actor may see, so looking one entry up is looking in that
137
+ // list. A separate statement with its own `WHERE prompt_name = ?` would be a second walk to
138
+ // keep in step with the first, for a set that is small by construction: the offered rows are
139
+ // a curated handful, not the tree.
140
+ const offered = await this.listOffered(actor);
141
+ return offered.find((entry) => entry.name === name) ?? null;
142
+ },
143
+ };
144
+ }
@@ -1,4 +1,5 @@
1
1
  import { descendantsCte, flowInSubtree, flowInSubtreeBindings, grantColumns as grantColumnsFor, grantInForce, mapGrant, nodeVerbBindings, nodeVerbQuery, principalColumns, subtreeBindings, subtreeCte, } from "./db-grants.js";
2
+ import { promptNameHolder } from "./db-prompts.js";
2
3
  const versionColumns = `id, node_id, sequence, content_key, media_type, content_hash,
3
4
  size, segment, created_by, created_at`;
4
5
  const grantColumns = grantColumnsFor("node_id");
@@ -17,7 +18,7 @@ function dedupedByNode(rows) {
17
18
  });
18
19
  }
19
20
  const nodeColumns = `n.id, n.parent_id, n.kind, n.title, n.description,
20
- n.owner_id, n.current_version_id, n.created_at, n.updated_at, n.archived_at`;
21
+ n.owner_id, n.current_version_id, n.created_at, n.updated_at, n.archived_at, n.prompt_name`;
21
22
  function mapNode(row) {
22
23
  return {
23
24
  id: row.id,
@@ -30,6 +31,7 @@ function mapNode(row) {
30
31
  createdAt: row.created_at,
31
32
  updatedAt: row.updated_at,
32
33
  archivedAt: row.archived_at,
34
+ promptName: row.prompt_name,
33
35
  };
34
36
  }
35
37
  function mapVersion(row) {
@@ -529,6 +531,22 @@ export function createNodeRepository(deps) {
529
531
  .all();
530
532
  return (result.results ?? []).map(mapVersion);
531
533
  },
534
+ /**
535
+ * ⚠️ **The prompt name is the one field here whose write can fail for a reason that is not a
536
+ * conflict, and its guard sits ON the statement rather than in front of it** (#775). A
537
+ * `SELECT` beforehand decides on a state that may be gone by the time the `UPDATE` lands; the
538
+ * condition below is evaluated inside the same transaction as the write it guards.
539
+ *
540
+ * ⚠️ **And it is on every statement of the batch, not only the first** — `destructive.md`,
541
+ * `anchrd/intel#457`. A statement matching zero rows is not an error and the batch commits
542
+ * anyway, so the idempotency row and the audit row carry the same `prompt_name IS ?` the
543
+ * `UPDATE` carries. Without it a refused rename would still be booked as done, and the replay
544
+ * path would hand the caller back a node it never wrote.
545
+ *
546
+ * ⚠️ `other.id <> ?` is what lets a node keep its OWN name. Writing the name it already holds
547
+ * is the everyday case — every `node_update` that changes a title sends the current prompt
548
+ * name along — and without the exclusion the node would be refused for colliding with itself.
549
+ */
532
550
  async updateNode(input) {
533
551
  const node = input.node;
534
552
  try {
@@ -542,10 +560,19 @@ export function createNodeRepository(deps) {
542
560
  JOIN descendants parent ON child.parent_id = parent.id
543
561
  )
544
562
  UPDATE nodes
545
- SET parent_id = ?, title = ?, description = ?, updated_at = ?
563
+ SET parent_id = ?, title = ?, description = ?, prompt_name = ?, updated_at = ?
546
564
  WHERE id = ? AND updated_at = ?
547
- AND (? IS NULL OR ? NOT IN (SELECT id FROM descendants))`)
548
- .bind(node.id, node.parentId, node.title, node.description, node.updatedAt, node.id, input.baseUpdatedAt, node.parentId, node.parentId),
565
+ AND (? IS NULL OR ? NOT IN (SELECT id FROM descendants))
566
+ AND (
567
+ ? IS NULL
568
+ OR (
569
+ NOT EXISTS (
570
+ SELECT 1 FROM nodes other WHERE other.prompt_name = ? AND other.id <> ?
571
+ )
572
+ AND NOT EXISTS (SELECT 1 FROM flows holder WHERE holder.prompt_name = ?)
573
+ )
574
+ )`)
575
+ .bind(node.id, node.parentId, node.title, node.description, node.promptName, node.updatedAt, node.id, input.baseUpdatedAt, node.parentId, node.parentId, node.promptName, node.promptName, node.id, node.promptName),
549
576
  deps.db
550
577
  .prepare(`INSERT INTO idempotency_keys (
551
578
  actor_id, operation, idempotency_key, resource_id, created_at
@@ -553,9 +580,9 @@ export function createNodeRepository(deps) {
553
580
  WHERE EXISTS (
554
581
  SELECT 1 FROM nodes
555
582
  WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ?
556
- AND updated_at = ?
583
+ AND prompt_name IS ? AND updated_at = ?
557
584
  )`)
558
- .bind(input.actorId, input.idempotencyKey, node.id, node.updatedAt, node.id, node.parentId, node.title, node.description, node.updatedAt),
585
+ .bind(input.actorId, input.idempotencyKey, node.id, node.updatedAt, node.id, node.parentId, node.title, node.description, node.promptName, node.updatedAt),
559
586
  deps.db
560
587
  .prepare(`INSERT INTO audit_events (
561
588
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
@@ -608,8 +635,33 @@ export function createNodeRepository(deps) {
608
635
  if (cycle?.found === 1)
609
636
  return "cycle";
610
637
  }
638
+ /**
639
+ * ⚠️ **Which of the two refusals it was, asked only once the write has already failed.** The
640
+ * condition on the statement is what makes the guard hold; this read is what makes the answer
641
+ * sayable. Doing it the other way round — deciding here and writing unguarded — is the shape
642
+ * that loses the race it was written for.
643
+ *
644
+ * ⚠️ It is asked of BOTH tables, because the name is one catalogue across the two (#775). A
645
+ * check against `nodes` alone answers "free" for a name a published flow holds, and the write
646
+ * would then keep failing with a message that says the node was changed by another editor.
647
+ */
648
+ if (node.promptName !== null) {
649
+ const taken = await deps.db
650
+ .prepare(`SELECT 1 AS found FROM nodes WHERE prompt_name = ? AND id <> ?
651
+ UNION ALL
652
+ SELECT 1 AS found FROM flows WHERE prompt_name = ?
653
+ LIMIT 1`)
654
+ .bind(node.promptName, node.id, node.promptName)
655
+ .first();
656
+ if (taken?.found === 1)
657
+ return "prompt_name_taken";
658
+ }
611
659
  return "conflict";
612
660
  },
661
+ async promptNameHolder(actor, name) {
662
+ // One question, one implementation, shared with the flow repository — see `db-prompts.ts`.
663
+ return await promptNameHolder(deps, actor, name);
664
+ },
613
665
  async archiveNode(input) {
614
666
  const archiving = input.archivedAt !== null;
615
667
  /**
@@ -992,6 +992,15 @@ export function createBundle(deps) {
992
992
  });
993
993
  }
994
994
  flows.push({
995
+ /**
996
+ * ⚠️ **An import never carries a slash command over, and that is not a gap** (#775). A
997
+ * prompt name is unique across the whole installation the bundle lands IN, and the
998
+ * bundle knows nothing about it: importing a flow offered as `/billing` into a tree
999
+ * that already has one would fail the whole import over a name nobody chose here. The
1000
+ * import brings the content; offering it is a decision somebody makes afterwards, in
1001
+ * the installation that has to live with the name.
1002
+ */
1003
+ promptName: null,
995
1004
  id: entry.newId,
996
1005
  parentId,
997
1006
  title: plainTitle(entry.title),
@@ -1075,6 +1084,9 @@ export function createBundle(deps) {
1075
1084
  if (version !== null)
1076
1085
  versions.push(version);
1077
1086
  nodes.push({
1087
+ // Not carried over, for the reason the flow half above spells out: the catalogue belongs
1088
+ // to the installation, not to the bundle (#775).
1089
+ promptName: null,
1078
1090
  id: entry.newId,
1079
1091
  parentId,
1080
1092
  // Flows took the `continue` above; what reaches here is one of the six node kinds.
@@ -1321,6 +1321,9 @@ export function createFlows(deps) {
1321
1321
  const occurredAt = deps.now().toISOString();
1322
1322
  return await deps.repository.insertFlow({
1323
1323
  flow: {
1324
+ // A flow is offered at `flow_publish` and never at creation: publishing is the decision
1325
+ // that it is for other people, and an unpublished flow is in nobody's catalogue (#775).
1326
+ promptName: null,
1324
1327
  id: deps.id(),
1325
1328
  parentId: input.parentId,
1326
1329
  title: plainTitle(input.title),
@@ -1641,7 +1644,26 @@ export function createFlows(deps) {
1641
1644
  idempotencyKey: input.idempotencyKey,
1642
1645
  auditId: deps.id(),
1643
1646
  occurredAt: deps.now().toISOString(),
1647
+ // ⚠️ Spread rather than assigned, and `exactOptionalPropertyTypes` is only half the reason.
1648
+ // The repository distinguishes three states, and "the field is present carrying
1649
+ // `undefined`" is not one of them: it has to be ABSENT for "leave the name alone".
1650
+ ...(input.promptName === undefined ? {} : { promptName: input.promptName }),
1644
1651
  });
1652
+ if (published === "prompt_name_taken") {
1653
+ /**
1654
+ * ⚠️ **The holder is named only to somebody who may see it** — the same sentence
1655
+ * `node_update` gives, out of the same query (`db-prompts.ts`). The catalogue is one across
1656
+ * documents and flows, so the name a flow is refused for may well be held by a document,
1657
+ * and the refusal has to be able to say so.
1658
+ */
1659
+ const holder = await deps.repository.promptNameHolder(actor, input.promptName);
1660
+ const held = holder === null
1661
+ ? "It is already taken."
1662
+ : holder.title === null
1663
+ ? `A ${holder.kind} you cannot see already uses it.`
1664
+ : `The ${holder.kind} "${holder.title}" already uses it.`;
1665
+ throw new IntelError(409, "prompt_name_taken", `The slash command "${input.promptName}" is not free. ${held} Pick another name — names are never changed for you, because the catalogue would then mean a different thing to every reader.`);
1666
+ }
1645
1667
  if (!published)
1646
1668
  throw new IntelError(409, "flow_publish_conflict", "Flow could not be published");
1647
1669
  return await requireFlow(actor, flow.id);
@@ -2,6 +2,7 @@ import type { ArchiveFlowInput, CreateFlowInput, Flow, FlowDocument, FlowGraph,
2
2
  import type { CancelFlowRunInput, CompleteFlowRunStepInput, FlowRun, FlowRunHistory, FlowRunList, FlowRunStep, ListFlowRunsInput, StartFlowRunInput } from "@anchrd/intel-contract/flow-run";
3
3
  import type { Node } from "@anchrd/intel-contract/node";
4
4
  import type { ResourceAccessList, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeFlowGrantInput, RevokeGrantResult, ShareFlowInput, ShareResult } from "@anchrd/intel-contract/share";
5
+ import type { PromptNameHolder } from "../prompts/prompts.types.js";
5
6
  export type FlowPrincipal = Pick<FlowActor, "id" | "email" | "isAdmin">;
6
7
  export type FlowCallReach = "subtree" | "library" | "out-of-reach";
7
8
  export interface FlowRunChainEntry {
@@ -159,7 +160,20 @@ export interface FlowRepository {
159
160
  idempotencyKey: string;
160
161
  auditId: string;
161
162
  occurredAt: string;
162
- }): Promise<boolean>;
163
+ /**
164
+ * The slash command this flow is offered under (#775). ⚠️ Three states, and `undefined` is the
165
+ * one that does nothing: publishing a second version of an already-offered flow must not
166
+ * withdraw its command, so a caller who says nothing about prompts changes nothing about them.
167
+ * `null` gives the name up, a string takes one.
168
+ */
169
+ promptName?: string | null;
170
+ }): Promise<boolean | "prompt_name_taken">;
171
+ /**
172
+ * Who holds a slash command name, for the sentence a refused publish gets. `title` is `null` when
173
+ * the holder exists but this actor may not see it — the same one implementation the node
174
+ * repository answers with (`db-prompts.ts`).
175
+ */
176
+ promptNameHolder(actor: FlowActor, name: string): Promise<PromptNameHolder | null>;
163
177
  unpublish(input: {
164
178
  flowId: string;
165
179
  versionId: string;
@@ -1,4 +1,4 @@
1
- import { BlockNoteDocument, BlockNoteMediaType } from "@anchrd/intel-contract/node";
1
+ import { documentText } from "../shared/document-text/document-text.js";
2
2
  export class PermanentIndexingError extends Error {
3
3
  }
4
4
  // The key under which a node with exactly one vector is filed — since #390 that is every kind. It
@@ -9,16 +9,15 @@ const wholeNodeChunkKey = "";
9
9
  // was archived while it worked and the record of them was refused (anchrd/intel#348).
10
10
  const writtenKeys = (chunks) => chunks.map((chunk) => chunk.key);
11
11
  function indexText(mediaType, content) {
12
- if (mediaType !== BlockNoteMediaType)
13
- return content;
14
- try {
15
- return BlockNoteDocument.parse(JSON.parse(content)).markdown;
16
- }
17
- catch (error) {
18
- // Immutable content never becomes parsable on a retry, so treating this as transient would
19
- // requeue the version until the queue gives up and would bury the real cause.
20
- throw new PermanentIndexingError(`Node version content is not a valid BlockNote document: ${error instanceof Error ? error.message : "unknown parse failure"}`);
12
+ const text = documentText(mediaType, content);
13
+ // Immutable content never becomes parsable on a retry, so treating this as transient would
14
+ // requeue the version until the queue gives up and would bury the real cause. `documentText`
15
+ // answers `null` rather than throwing, because the other caller — `prompts/get` — owes its reader
16
+ // a different sentence about the same broken payload (#775).
17
+ if (text === null) {
18
+ throw new PermanentIndexingError("Node version content is not a valid BlockNote document");
21
19
  }
20
+ return text;
22
21
  }
23
22
  /**
24
23
  * What one chunk is embedded as (anchrd/intel#301).
@@ -187,6 +187,7 @@ export function createIntel(deps) {
187
187
  audit: deps.audit,
188
188
  feed: deps.feed,
189
189
  boards: deps.boards,
190
+ prompts: deps.prompts,
190
191
  });
191
192
  });
192
193
  return app;
@@ -6,6 +6,7 @@ import type { BundleService } from "../bundle/bundle.types.js";
6
6
  import type { FeedService } from "../feed/feed.types.js";
7
7
  import type { FlowService } from "../flows/flows.types.js";
8
8
  import type { NodeService } from "../nodes/nodes.types.js";
9
+ import type { Prompts } from "../prompts/prompts.types.js";
9
10
  import type { ToolService } from "../tools/tools.types.js";
10
11
  export interface IntelDeps {
11
12
  baseUrl: string;
@@ -18,5 +19,6 @@ export interface IntelDeps {
18
19
  audit: AuditService;
19
20
  feed: FeedService;
20
21
  boards: BoardService;
22
+ prompts: Prompts;
21
23
  auth?: BrowserAuth;
22
24
  }
package/dist/mcp/mcp.js CHANGED
@@ -10,6 +10,7 @@ import { AppendTableRowsInput, DefineTableInput, DeleteTableRowsInput, GetTableI
10
10
  import { ExecuteToolInput, TestToolInput } from "@anchrd/intel-contract/tool";
11
11
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
12
12
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
13
+ import { GetPromptRequestSchema, ListPromptsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
13
14
  import { z } from "zod";
14
15
  import { permits } from "../shared/gate-authorization/gate-authorization.js";
15
16
  import { IntelError } from "../shared/intel-error/intel-error.js";
@@ -1236,6 +1237,55 @@ export async function handleMcp(request, deps) {
1236
1237
  },
1237
1238
  }, async (input) => text(await deps.tools.execute(toolActor, input)));
1238
1239
  }
1240
+ /**
1241
+ * The slash-command surface (#775). A prompt is the one thing on this server the HUMAN chooses:
1242
+ * a tool is called when the model thinks of it, a prompt when somebody types `/name`.
1243
+ *
1244
+ * ⚠️ **Registered through the low-level handlers rather than `registerPrompt`, and the cost is
1245
+ * the reason.** `registerPrompt` takes one name at a time, so a catalogue that lives in D1 would
1246
+ * have to be read and registered while the server is being built — on EVERY request, including
1247
+ * every `tools/call` that will never look at a prompt. These two handlers run the queries only
1248
+ * when a client actually asks for prompts.
1249
+ *
1250
+ * ⚠️ **The capability is declared here because nothing else declares it.** Without
1251
+ * `registerCapabilities` the SDK would answer `prompts/list` while telling clients during
1252
+ * initialization that this server has no prompts, and a client that believes the handshake never
1253
+ * asks. `listChanged: false` is the truthful value: the catalogue changes when somebody edits a
1254
+ * document, and this server sends no notification about it.
1255
+ *
1256
+ * ⚠️ **Offered on EITHER capability, decided per call** — the shape `audit_list` uses one screen
1257
+ * up, for the same reason. Holding one of the two is what makes the surface worth offering at
1258
+ * all; which half of the catalogue it then answers is `listOffered`'s question, and it asks it
1259
+ * per row against the resource ACL underneath.
1260
+ */
1261
+ if (permits(deps.authorization, "nodes", "read") ||
1262
+ permits(deps.authorization, "flows", "read")) {
1263
+ const promptActor = {
1264
+ ...actor,
1265
+ canReadNodes: permits(deps.authorization, "nodes", "read"),
1266
+ canReadFlows: permits(deps.authorization, "flows", "read"),
1267
+ };
1268
+ server.server.registerCapabilities({ prompts: { listChanged: false } });
1269
+ server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
1270
+ prompts: (await deps.prompts.list(promptActor)).map((entry) => ({
1271
+ name: entry.name,
1272
+ title: entry.title,
1273
+ // ⚠️ No `arguments`, and that is a decision rather than a gap (#775). A prompt without
1274
+ // arguments is completely usable — it is an instruction somebody wrote down — and the
1275
+ // obvious source of an argument schema, a flow's requirements, is a round of its own.
1276
+ // Declaring an empty `arguments` array would tell a client the question was asked and
1277
+ // answered "none", which is a different claim.
1278
+ ...(entry.description === null ? {} : { description: entry.description }),
1279
+ })),
1280
+ }));
1281
+ server.server.setRequestHandler(GetPromptRequestSchema, async (promptRequest) => {
1282
+ const { entry, text } = await deps.prompts.get(promptActor, promptRequest.params.name);
1283
+ return {
1284
+ ...(entry.description === null ? {} : { description: entry.description }),
1285
+ messages: [{ role: "user", content: { type: "text", text } }],
1286
+ };
1287
+ });
1288
+ }
1239
1289
  const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
1240
1290
  await server.connect(transport);
1241
1291
  return await transport.handleRequest(request);
@@ -5,6 +5,7 @@ import type { BundleService } from "../bundle/bundle.types.js";
5
5
  import type { FeedService } from "../feed/feed.types.js";
6
6
  import type { FlowService } from "../flows/flows.types.js";
7
7
  import type { NodeService } from "../nodes/nodes.types.js";
8
+ import type { Prompts } from "../prompts/prompts.types.js";
8
9
  import type { ToolService } from "../tools/tools.types.js";
9
10
  export interface McpDeps {
10
11
  authorization: Authorized;
@@ -20,4 +21,5 @@ export interface McpDeps {
20
21
  audit: AuditService;
21
22
  feed: FeedService;
22
23
  boards: BoardService;
24
+ prompts: Prompts;
23
25
  }
@@ -637,6 +637,11 @@ export function createNodes(deps) {
637
637
  const timestamp = deps.now().toISOString();
638
638
  const created = await deps.repository.insertNode({
639
639
  node: {
640
+ // ⚠️ A node is never born offered (#775). Naming a slash command is a curation decision
641
+ // about a document that already exists, and `node_create` takes no name: a creation that
642
+ // could also take the last free name in the catalogue would fail for a reason that has
643
+ // nothing to do with creating anything.
644
+ promptName: null,
640
645
  id: deps.id(),
641
646
  parentId: input.parentId,
642
647
  kind: input.kind,
@@ -997,6 +1002,22 @@ export function createNodes(deps) {
997
1002
  if (!(await deps.repository.can(actor, current.id, "write"))) {
998
1003
  throw new IntelError(403, "node_forbidden", "This node cannot be edited");
999
1004
  }
1005
+ /**
1006
+ * ⚠️ **Only a document can be offered as a slash command, and the refusal names the kind**
1007
+ * (#775). A prompt is an instruction; a folder, a table, an attachment, a board or a task is
1008
+ * material, and material is read through `node_get` rather than typed as a command.
1009
+ *
1010
+ * ⚠️ It stands BEFORE the idempotency replay on purpose: a call that is wrong about the kind
1011
+ * is wrong on every attempt, and answering the second one out of the replay table would tell
1012
+ * a caller their refused write had succeeded.
1013
+ *
1014
+ * ⚠️ Clearing it — `null` on a kind that could never have had one — is deliberately allowed.
1015
+ * It changes nothing, and refusing a no-op would make "take the command off everything in
1016
+ * this folder" a call that fails on the folder itself.
1017
+ */
1018
+ if (input.promptName != null && current.kind !== "document") {
1019
+ throw new IntelError(409, "prompt_not_a_document", `Only a document can be offered as a slash command, and this is a ${current.kind}.`);
1020
+ }
1000
1021
  const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.update", input.idempotencyKey);
1001
1022
  if (replayedId)
1002
1023
  return await requireVisible(actor, replayedId);
@@ -1045,6 +1066,7 @@ export function createNodes(deps) {
1045
1066
  parentId: input.parentId === undefined ? current.parentId : input.parentId,
1046
1067
  title: input.title === undefined ? current.title : plainTitle(input.title),
1047
1068
  description: input.description === undefined ? current.description : input.description,
1069
+ promptName: input.promptName === undefined ? current.promptName : input.promptName,
1048
1070
  updatedAt,
1049
1071
  },
1050
1072
  baseUpdatedAt: input.baseUpdatedAt,
@@ -1055,6 +1077,22 @@ export function createNodes(deps) {
1055
1077
  if (updated === "cycle") {
1056
1078
  throw new IntelError(409, "move_cycle", "A node cannot be moved into its descendant");
1057
1079
  }
1080
+ if (updated === "prompt_name_taken") {
1081
+ /**
1082
+ * ⚠️ **The sentence names the holder only when this actor may see it.** That a name is
1083
+ * taken has to be said — otherwise the caller retries forever against a wall they cannot
1084
+ * see — but WHICH document holds it is a fact about the tree, and ADR-0004 §3 does not let
1085
+ * a refusal become a way of reading it. `promptNameHolder` answers `null` for the title in
1086
+ * that case, and the second sentence below is what the caller gets instead.
1087
+ */
1088
+ const holder = await deps.repository.promptNameHolder(actor, input.promptName);
1089
+ const held = holder === null
1090
+ ? "It is already taken."
1091
+ : holder.title === null
1092
+ ? `A ${holder.kind} you cannot see already uses it.`
1093
+ : `The ${holder.kind} "${holder.title}" already uses it.`;
1094
+ throw new IntelError(409, "prompt_name_taken", `The slash command "${input.promptName}" is not free. ${held} Pick another name — names are never changed for you, because the catalogue would then mean a different thing to every reader.`);
1095
+ }
1058
1096
  if (updated === "conflict") {
1059
1097
  throw new IntelError(409, "update_conflict", "This node was changed by another editor");
1060
1098
  }
@@ -3,6 +3,7 @@ import type { ArchiveNodeInput, CreateNodeInput, ListNodesInput, Node, NodeAttac
3
3
  import type { ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, ShareInput, ShareResult } from "@anchrd/intel-contract/share";
4
4
  import type { AppendTableRowsInput, AppendTableRowsResult, DefineTableInput, DeleteTableRowsInput, DeleteTableRowsResult, RedefineTableInput, UpdateTableRowsInput, UpdateTableRowsResult } from "@anchrd/intel-contract/table";
5
5
  import type { SemanticIndex } from "../adapters/semantic-index/semantic-index.types.js";
6
+ import type { PromptNameHolder } from "../prompts/prompts.types.js";
6
7
  export interface Actor {
7
8
  id: string;
8
9
  email: string;
@@ -78,7 +79,13 @@ export interface NodeRepository {
78
79
  actorId: string;
79
80
  idempotencyKey: string;
80
81
  auditId: string;
81
- }): Promise<"cycle" | "conflict" | Node>;
82
+ }): Promise<"cycle" | "conflict" | "prompt_name_taken" | Node>;
83
+ /**
84
+ * Who holds a slash command name, for the sentence a refused rename gets (#775). `title` is
85
+ * `null` when the holder exists but this actor may not see it — the name is taken either way, and
86
+ * which document it is stays behind the same wall every other read stands behind.
87
+ */
88
+ promptNameHolder(actor: Actor, name: string): Promise<PromptNameHolder | null>;
82
89
  archiveNode(input: {
83
90
  nodeId: string;
84
91
  baseUpdatedAt: string;
@@ -0,0 +1,2 @@
1
+ import type { Prompts, PromptsDeps } from "./prompts.types.js";
2
+ export declare function createPrompts(deps: PromptsDeps): Prompts;
@@ -0,0 +1,65 @@
1
+ import { documentText } from "../shared/document-text/document-text.js";
2
+ import { IntelError } from "../shared/intel-error/intel-error.js";
3
+ /**
4
+ * What a client is handed when it opens the command of a FLOW.
5
+ *
6
+ * ⚠️ **This text starts nothing, and that is the point** (D24: the agent acts, Intel does not).
7
+ * `prompts/get` is a read — a model asked for the wording of a command, not for the command to
8
+ * happen. Returning instructions rather than performing them keeps the one property that makes
9
+ * Intel safe to point a model at: nothing in this package moves because something was READ.
10
+ *
11
+ * ⚠️ It names the `flowId` rather than the prompt name, because the name is not what the next call
12
+ * takes. A model handed "run the flow `crm-outreach`" has to translate that back into an id, and
13
+ * the translation is exactly the step it gets wrong — `flow_run_start` takes an id and refuses a
14
+ * name with a message about the id being unknown.
15
+ */
16
+ function flowInstruction(entry) {
17
+ const description = entry.description === null ? "" : `\n\n${entry.description}`;
18
+ return `Start the Intel flow "${entry.title}" by calling \`flow_run_start\` with \`flowId: "${entry.targetId}"\`. Do not describe the flow instead of starting it, and do not invent a different id: this one is the flow the command \`/${entry.name}\` stands for.${description}`;
19
+ }
20
+ export function createPrompts(deps) {
21
+ return {
22
+ async list(actor) {
23
+ return await deps.repository.listOffered(actor);
24
+ },
25
+ async get(actor, name) {
26
+ const entry = await deps.repository.findOffered(actor, name);
27
+ /**
28
+ * ⚠️ **One refusal for "no such command" and for "not yours to see", and it says the first.**
29
+ * `findOffered` searches the catalogue as this actor sees it, so a document offered under
30
+ * this name inside a folder they may not read is simply absent from it. Two different
31
+ * messages here would make the refusal a way of asking whether a command exists — the same
32
+ * reading of the tree ADR-0004 §3 refuses everywhere else.
33
+ */
34
+ if (entry === null) {
35
+ throw new IntelError(404, "prompt_not_found", "No command by that name is offered to you. `prompts/list` names the ones that are.");
36
+ }
37
+ if (entry.kind === "flow")
38
+ return { entry, text: flowInstruction(entry) };
39
+ const document = await deps.readDocument(actor, entry.targetId);
40
+ /**
41
+ * ⚠️ **A document with no version yet is an EMPTY command, not a failure.** Somebody offered a
42
+ * document they have not written in; the honest answer is the empty instruction they have.
43
+ */
44
+ if (document.version === null || document.content === null)
45
+ return { entry, text: "" };
46
+ /**
47
+ * ⚠️ **What a document node stores is not its text.** The editor writes a BlockNote payload,
48
+ * and handing that on gives a model a JSON blob where its instruction should be — for
49
+ * practically every document written in the app. `documentText` is the same extraction the
50
+ * search index runs, out of the same function, so the words a model is given and the words
51
+ * somebody can search for cannot drift apart.
52
+ */
53
+ const text = documentText(document.version.mediaType, document.content);
54
+ /**
55
+ * ⚠️ And an unreadable payload is REFUSED rather than passed on raw. Half a JSON document as
56
+ * an instruction is the failure this whole finding is about, one step further along: the
57
+ * model would act on it rather than report it.
58
+ */
59
+ if (text === null) {
60
+ throw new IntelError(422, "prompt_unreadable", `The document behind /${entry.name} is not stored in a form this command can be read from. Open it once and save it, or offer a document written in the editor.`);
61
+ }
62
+ return { entry, text };
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,71 @@
1
+ import type { NodeDocument } from "@anchrd/intel-contract/node";
2
+ import type { Actor } from "../nodes/nodes.types.js";
3
+ /**
4
+ * One entry of the slash-command catalogue (#775): a document offered as an instruction, or a
5
+ * published flow offered as a way to start one.
6
+ *
7
+ * ⚠️ `kind` is two values and not `NodeKind`. A flow is not a node, and the five node kinds that
8
+ * cannot be offered have no business appearing in this union — a widened type here would be a
9
+ * standing invitation to hand `prompts/list` a folder.
10
+ */
11
+ export interface PromptEntry {
12
+ name: string;
13
+ kind: "document" | "flow";
14
+ targetId: string;
15
+ title: string;
16
+ description: string | null;
17
+ }
18
+ /**
19
+ * Who holds a name, as much of it as the asking actor is entitled to learn.
20
+ *
21
+ * ⚠️ `title` is `null` when the holder exists but this actor may not see it, and that is not the
22
+ * same as "no holder". ADR-0004 §3 is explicit that a refusal must not become a way of reading the
23
+ * tree: naming the document that holds `payroll` would tell somebody without access that a document
24
+ * by that title exists. The caller is told the name is taken — which they must be, or they cannot
25
+ * proceed — and nothing else.
26
+ */
27
+ export interface PromptNameHolder {
28
+ kind: "document" | "flow";
29
+ title: string | null;
30
+ }
31
+ /**
32
+ * Who is asking for the catalogue, and which halves of it they are entitled to at all.
33
+ *
34
+ * ⚠️ **The capability follows the KIND, exactly as it does for `audit_list`** (#774). A prompt
35
+ * entry says the same thing its resource says, so listing it asks what reading that resource asks:
36
+ * `nodes/read` for a document, `flows/read` for a flow. Somebody holding only one of the two gets
37
+ * the half they hold — not an empty list, and not the other half by accident.
38
+ *
39
+ * ⚠️ These are the GATE capabilities and they are the coarse half of the answer. The resource ACL
40
+ * still runs underneath, per row, in the query itself; a caller with `nodes/read` sees the offered
41
+ * documents THEY may read and no others.
42
+ */
43
+ export interface PromptActor extends Actor {
44
+ canReadNodes: boolean;
45
+ canReadFlows: boolean;
46
+ }
47
+ export interface PromptRepository {
48
+ listOffered(actor: PromptActor): Promise<PromptEntry[]>;
49
+ findOffered(actor: PromptActor, name: string): Promise<PromptEntry | null>;
50
+ }
51
+ export interface PromptsDeps {
52
+ repository: PromptRepository;
53
+ /**
54
+ * One document, as the node service reads it: the same authorization walk, the same content
55
+ * store. `prompts/get` does not reach into R2 on its own — a second reader would be a second
56
+ * place for the ACL to be applied, and the one that gets it wrong is the one nobody looks at.
57
+ *
58
+ * ⚠️ **The whole document, not a string.** What is stored under a document node is the editor's
59
+ * payload, and turning that into readable text needs the `mediaType` beside the content. Handing
60
+ * a string in would put that decision in whatever wired this up — in the Cloudflare shell, which
61
+ * is the one place in this package that may not hold business behaviour.
62
+ */
63
+ readDocument(actor: Actor, nodeId: string): Promise<NodeDocument>;
64
+ }
65
+ export interface Prompts {
66
+ list(actor: PromptActor): Promise<PromptEntry[]>;
67
+ get(actor: PromptActor, name: string): Promise<{
68
+ entry: PromptEntry;
69
+ text: string;
70
+ }>;
71
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The READABLE text of one stored version, for the two places that need a document as prose rather
3
+ * than as the editor's own payload.
4
+ *
5
+ * ⚠️ **What a document node stores is NOT its text.** The editor writes a BlockNote payload —
6
+ * `{ blocks, markdown, … }` — and `markdown` beside the blocks is the whole of what anybody outside
7
+ * the editor can read. `packages/ui/CLAUDE.md` states it for the search: `indexing.ts` pulls that
8
+ * field and nothing else into full text and vectors, so it is what anyone searching Intel matches
9
+ * against. A caller who hands the raw content on instead is handing on a JSON blob.
10
+ *
11
+ * ⚠️ **Every other media type travels unchanged**, and that is not an oversight: a `text/markdown`
12
+ * version IS its text, and a table renders its own. Only the editor's payload has a wrapper around
13
+ * the words.
14
+ *
15
+ * ⚠️ **`null` means "this content is not readable as text", not "empty".** Immutable content never
16
+ * becomes parsable on a retry, so the two callers answer it differently and each is right for its
17
+ * own surface: the indexer refuses the version permanently rather than requeueing it forever, and
18
+ * `prompts/get` refuses the call rather than handing a model half a JSON document. Returning `""`
19
+ * here would collapse both into "there is nothing to say".
20
+ */
21
+ export declare function documentText(mediaType: string, content: string): string | null;
@@ -0,0 +1,31 @@
1
+ import { BlockNoteDocument, BlockNoteMediaType } from "@anchrd/intel-contract/node";
2
+ /**
3
+ * The READABLE text of one stored version, for the two places that need a document as prose rather
4
+ * than as the editor's own payload.
5
+ *
6
+ * ⚠️ **What a document node stores is NOT its text.** The editor writes a BlockNote payload —
7
+ * `{ blocks, markdown, … }` — and `markdown` beside the blocks is the whole of what anybody outside
8
+ * the editor can read. `packages/ui/CLAUDE.md` states it for the search: `indexing.ts` pulls that
9
+ * field and nothing else into full text and vectors, so it is what anyone searching Intel matches
10
+ * against. A caller who hands the raw content on instead is handing on a JSON blob.
11
+ *
12
+ * ⚠️ **Every other media type travels unchanged**, and that is not an oversight: a `text/markdown`
13
+ * version IS its text, and a table renders its own. Only the editor's payload has a wrapper around
14
+ * the words.
15
+ *
16
+ * ⚠️ **`null` means "this content is not readable as text", not "empty".** Immutable content never
17
+ * becomes parsable on a retry, so the two callers answer it differently and each is right for its
18
+ * own surface: the indexer refuses the version permanently rather than requeueing it forever, and
19
+ * `prompts/get` refuses the call rather than handing a model half a JSON document. Returning `""`
20
+ * here would collapse both into "there is nothing to say".
21
+ */
22
+ export function documentText(mediaType, content) {
23
+ if (mediaType !== BlockNoteMediaType)
24
+ return content;
25
+ try {
26
+ return BlockNoteDocument.parse(JSON.parse(content)).markdown;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
@@ -0,0 +1,70 @@
1
+ -- A document and a published flow can both be offered as a slash command (#775). What they need is
2
+ -- one short name, and the name has to be unique across BOTH of them: they land in the same
3
+ -- `prompts/list` catalogue, so a document and a flow can take each other's name.
4
+ --
5
+ -- ⚠️ **One field, not two.** `prompt_name` set means offered, `NULL` means not. A boolean beside it
6
+ -- would allow the state "on, without a name", and that is not a thing `prompts/list` could answer.
7
+ --
8
+ -- ⚠️ **No third table, and the choice is deliberate.** A `prompt_names(name PRIMARY KEY, …)`
9
+ -- catalogue would carry the cross-kind uniqueness as a real constraint, and it would cost two
10
+ -- things this schema is not willing to pay: a second truth about the name of one node — every
11
+ -- `SELECT` over `nodes` would have to remember a join, and the one that forgets it answers `NULL`
12
+ -- in silence — and a fourth child table for the `nodes` rebuild recipe to carry (see the package
13
+ -- CLAUDE.md; `node_links` is the one that already has to be carried out and back). The uniqueness
14
+ -- WITHIN one kind is an index here; the uniqueness ACROSS the two is a condition on the write, on
15
+ -- every statement that sets a name, in the same batch that writes it.
16
+ --
17
+ -- ⚠️ The condition on the write is not a nicety. A `SELECT` before an `UPDATE` in a separate call
18
+ -- decides on a state that may be gone by the time the write lands; the check has to be part of the
19
+ -- statement so that the two cannot come apart. What the caller is told — WHICH node or flow holds
20
+ -- the name — is a second, cheap read that only runs once the write has already refused.
21
+
22
+ -- ⚠️ **This form is MEASURED against real D1, not only against miniflare.** No other file in this
23
+ -- folder adds a `CHECK` through `ALTER TABLE … ADD COLUMN` that reads a DIFFERENT column, and the
24
+ -- package CLAUDE.md is explicit that a green run under the emulator proves nothing about the
25
+ -- deployment — `foreign_keys = OFF` is honoured there and ignored by D1 over its HTTP API. A
26
+ -- migration file is immutable after release, so the failure would surface at the deploy.
27
+ --
28
+ -- Run against a throwaway D1 (`intel-check-0028`, `--remote`) on 2026-08-25 and then deleted. Both
29
+ -- statements applied, `sqlite_master` shows the CHECK on the column, and all six refusals fire with
30
+ -- `SQLITE_CONSTRAINT_CHECK` / `SQLITE_CONSTRAINT_UNIQUE`: a folder and a table with a name, an
31
+ -- uppercase name, a name with a space, a 41-character name, and a second row taking a name that is
32
+ -- already held. A document with a valid name and two rows with none go through.
33
+ --
34
+ -- ⚠️ `kind = 'document'` is in the CHECK, not only in the service. A prompt is an instruction, not
35
+ -- material: a folder, a table, an attachment, a board or a task never carries one (#775). The
36
+ -- service refuses it with a sentence, and this line is what makes that refusal true even for a
37
+ -- write that never passes the service.
38
+ --
39
+ -- ⚠️ `NOT GLOB '*[^a-z0-9-]*'` is the grammar `^[a-z0-9-]{1,40}$` says on the contract, spelled the
40
+ -- one way SQLite can spell it: "no character outside the class anywhere". The obvious
41
+ -- `GLOB '[a-z0-9-]*'` is NOT that check — it constrains the first character and lets every other
42
+ -- one through, so `a_B C` passes it. The hyphen sits last inside the class, where it is a literal
43
+ -- rather than a range.
44
+ ALTER TABLE nodes ADD COLUMN prompt_name TEXT
45
+ CHECK (
46
+ prompt_name IS NULL
47
+ OR (
48
+ kind = 'document'
49
+ AND length(prompt_name) BETWEEN 1 AND 40
50
+ AND prompt_name NOT GLOB '*[^a-z0-9-]*'
51
+ )
52
+ );
53
+
54
+ ALTER TABLE flows ADD COLUMN prompt_name TEXT
55
+ CHECK (
56
+ prompt_name IS NULL
57
+ OR (length(prompt_name) BETWEEN 1 AND 40 AND prompt_name NOT GLOB '*[^a-z0-9-]*')
58
+ );
59
+
60
+ -- ⚠️ Partial, and that is the whole reason they work. A plain `UNIQUE` index would let several
61
+ -- rows carry `NULL` too — SQLite treats NULLs as distinct — so the `WHERE` clause buys no
62
+ -- correctness here; it buys the SIZE. Almost every node has no prompt name, and an index over
63
+ -- them all would be one entry per node in the tree to serve a handful of rows.
64
+ --
65
+ -- ⚠️ These two carry the uniqueness WITHIN a kind and nothing more. Two documents cannot share a
66
+ -- name and two flows cannot; a document and a flow can, as far as these indexes are concerned, and
67
+ -- the condition on the write is the only thing that stops them. Reading one of these index names in
68
+ -- a query plan is therefore not evidence that the cross-kind rule held.
69
+ CREATE UNIQUE INDEX nodes_prompt_name_idx ON nodes(prompt_name) WHERE prompt_name IS NOT NULL;
70
+ CREATE UNIQUE INDEX flows_prompt_name_idx ON flows(prompt_name) WHERE prompt_name IS NOT NULL;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.40.0",
3
+ "version": "0.41.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.26.0",
46
- "@anchrd/intel-contract": "^0.32.0",
46
+ "@anchrd/intel-contract": "^0.33.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",