@anchrd/intel-api 0.39.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.
@@ -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
  /**
@@ -47,25 +47,40 @@ function decodeCursor(cursor) {
47
47
  export function createAudit(deps) {
48
48
  return {
49
49
  async list(actor, input) {
50
- // `resourceType` is validated by the contract enum before it reaches here; the value is
51
- // named again rather than assumed, so adding `flow` to the enum later cannot silently route
52
- // flow events through the node visibility walk.
53
- if (input.resourceType !== "node") {
54
- throw new IntelError(400, "unsupported_resource_type", "Only node events can be listed today. Flow and flow-run events need their own visibility check and are refused rather than omitted, so that an empty answer never has to mean two different things.");
55
- }
56
50
  const after = input.after === undefined ? null : decodeCursor(input.after);
57
- // One more than asked for, so "is there another page" is answered by the same authorized
51
+ // ⚠️ The refusal that stood here until #774 is GONE, and it was doing real work: it named
52
+ // `node` explicitly so that widening the contract enum could not silently route flow events
53
+ // through the node visibility walk. What replaces it is not trust but a repository that takes
54
+ // the kind as an argument and picks a statement per kind — each with its own walk, none able
55
+ // to answer for another. Adding a fourth kind to the enum is now a type error there rather
56
+ // than a wrong answer here, which is the stronger version of the same guard.
57
+ //
58
+ // One more row than asked for, so "is there another page" is answered by the same authorized
58
59
  // query instead of a second one that could disagree with it.
59
- const page = await deps.audit.listNodeEvents(actor, { after, limit: input.limit + 1 });
60
+ const page = await deps.audit.listEvents(actor, {
61
+ after,
62
+ resourceType: input.resourceType,
63
+ limit: input.limit + 1,
64
+ });
60
65
  const hasMore = page.events.length > input.limit;
61
66
  const events = hasMore ? page.events.slice(0, input.limit) : page.events;
62
67
  const last = events.at(-1);
63
68
  return {
64
69
  events,
65
- // ⚠️ The cursor is the LAST DELIVERED row, never the probe row that was dropped. Taking it
66
- // from the probe would skip exactly one event per page — the classic off-by-one that shows
67
- // up as a lead nobody contacted, not as an error.
68
- nextCursor: hasMore && last !== undefined ? encodeCursor(last) : null,
70
+ /**
71
+ * ⚠️ The cursor is the LAST DELIVERED row, never the probe row that was dropped. Taking it
72
+ * from the probe would skip exactly one event per page — the classic off-by-one that shows
73
+ * up as a lead nobody contacted, not as an error.
74
+ *
75
+ * ⚠️ **And it is returned whenever rows were delivered, not only when more follows.** It
76
+ * used to carry `hasMore &&`, which meant a reader catching up received fifty events and no
77
+ * position: it started over on its next run and would have delivered the same fifty for
78
+ * ever. A reader paging through never sees it, because it always asks for a full page —
79
+ * which is why this survived until `anchrd/signals` read the journal for real (`#793`).
80
+ */
81
+ nextCursor: last === undefined ? null : encodeCursor(last),
82
+ // Whether another page may follow. Its own field, because it is its own question.
83
+ hasMore,
69
84
  };
70
85
  },
71
86
  };
@@ -1,4 +1,4 @@
1
- import type { AuditEvent, AuditListRequest, AuditListResponse } from "@anchrd/intel-contract/audit";
1
+ import type { AuditEvent, AuditListRequest, AuditListResponse, AuditResourceType } from "@anchrd/intel-contract/audit";
2
2
  import type { Actor } from "../nodes/nodes.types.js";
3
3
  export interface AuditPosition {
4
4
  occurredAt: string;
@@ -6,13 +6,14 @@ export interface AuditPosition {
6
6
  }
7
7
  export interface AuditQuery {
8
8
  after: AuditPosition | null;
9
+ resourceType: AuditResourceType;
9
10
  limit: number;
10
11
  }
11
12
  export interface AuditPage {
12
13
  events: AuditEvent[];
13
14
  }
14
15
  export interface AuditRepository {
15
- listNodeEvents(actor: Actor, query: AuditQuery): Promise<AuditPage>;
16
+ listEvents(actor: Actor, query: AuditQuery): Promise<AuditPage>;
16
17
  }
17
18
  export interface AuditDeps {
18
19
  audit: AuditRepository;
@@ -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);
@@ -1776,11 +1798,16 @@ export function createFlows(deps) {
1776
1798
  async listRuns(actor, input) {
1777
1799
  // The history of a flow that has since been archived stays readable — the runs happened, and
1778
1800
  // archiving is about what may start next (#112).
1779
- const flow = await requireStartedFlow(actor, input.flowId);
1801
+ //
1802
+ // ⚠️ Without a flow there is nothing to require, and skipping the check does NOT widen the
1803
+ // answer (#774): `requireStartedFlow` decides whether one named flow exists FOR this actor,
1804
+ // while which runs appear is decided per row in the repository — their own, plus every run of
1805
+ // a flow they may read. Asking it for a flow nobody named would mean inventing one.
1806
+ const flowId = input.flowId === null ? null : (await requireStartedFlow(actor, input.flowId)).id;
1780
1807
  // One row beyond the page: it answers "is there more" and is dropped rather than shown, so a
1781
1808
  // count over the whole table is never needed to draw a "next" affordance.
1782
1809
  const rows = await deps.repository.listRunsVisible(actor, {
1783
- flowId: flow.id,
1810
+ flowId,
1784
1811
  failedOnly: input.failedOnly,
1785
1812
  limit: input.limit + 1,
1786
1813
  cursor: decodeCursor(input.cursor),
@@ -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 {
@@ -13,7 +14,7 @@ export interface FlowRunChainEntry {
13
14
  currentNodeId: string | null;
14
15
  }
15
16
  export interface ListRunsQuery {
16
- flowId: string;
17
+ flowId: string | null;
17
18
  failedOnly: boolean;
18
19
  limit: number;
19
20
  cursor: {
@@ -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;
package/dist/http/http.js CHANGED
@@ -207,16 +207,25 @@ export function createHttp(deps) {
207
207
  const input = GetNodeInput.parse({ nodeId: context.req.param("nodeId") });
208
208
  return zipResponse(await deps.bundle.exportSubtree(asBundleActor(auth), input.nodeId));
209
209
  });
210
- // The change journal, read forward from a stable position (#620). `nodes/read` and nothing new:
211
- // an audit row about a node says the same thing the node says, so a second capability would be a
212
- // second answer to one question. The per-row check is the service's, and it is the same tree walk
213
- // the node reads go through.
210
+ // The change journal, read forward from a stable position (#620).
211
+ //
212
+ // ⚠️ **The capability follows the KIND asked for** (#774). An audit row says the same thing its
213
+ // resource says, so the journal asks what reading that resource asks: `nodes/read` for `node`,
214
+ // `flows/read` for `flow` and `flow-run` — which is what every other flow-delivering route in
215
+ // this file requires. Until the enum was widened one capability covered the one kind there was;
216
+ // leaving it at `nodes/read` afterwards would have dropped the first of the three checks for
217
+ // exactly the two kinds this ticket opened, and left the Intel ACL as the only filter.
218
+ //
219
+ // ⚠️ The parse therefore runs BEFORE the capability check, which is the one place in this file
220
+ // where it does. That is not a loosening of "capability first, so a refusal costs no storage
221
+ // read": parsing touches the query string and nothing else, and the kind cannot be known any
222
+ // earlier than this. What it costs is that a malformed `resourceType` answers 400 rather than
223
+ // 403 — a sentence about the caller's own parameter, which names nothing of Intel's.
214
224
  //
215
225
  // ⚠️ GET with the cursor in the query string, because the verb is one of the four in the grammar
216
226
  // and the method carries it. `limit` arrives as a string and is coerced here — the contract
217
227
  // wants a number, and a query string has none.
218
228
  app.get("/audit", async (context) => {
219
- const auth = requireCapability(context, "nodes", "read");
220
229
  const limit = context.req.query("limit");
221
230
  const after = context.req.query("after");
222
231
  const input = AuditListRequest.parse({
@@ -224,10 +233,19 @@ export function createHttp(deps) {
224
233
  ...(after === undefined ? {} : { after }),
225
234
  ...(limit === undefined ? {} : { limit: Number(limit) }),
226
235
  });
236
+ const auth = requireCapability(context, input.resourceType === "node" ? "nodes" : "flows", "read");
227
237
  return context.json(await deps.audit.list(asActor(auth), input));
228
238
  });
229
239
  // The same journal read the other way round: newest first, for a person rather than for a
230
- // consumer catching up (#740). `nodes/read` and nothing new, for the reason `/audit` gives.
240
+ // consumer catching up (#740).
241
+ //
242
+ // ⚠️ `nodes/read` for the whole answer, and here that CANNOT follow the kind the way `/audit`
243
+ // above does: one page mixes node and flow cards on purpose, so there is no single kind to pick a
244
+ // capability from. It has been that way since flow cards existed and #767 only added two actions
245
+ // to them, so nothing about it is new — but it does mean somebody holding `nodes/read` without
246
+ // `flows/read` still sees flow activity here, filtered by the Intel ACL alone. Narrowing it means
247
+ // filtering cards by capability inside the feed service, which is a change to the answer rather
248
+ // than to the door: #790.
231
249
  //
232
250
  // ⚠️ A SECOND door and not an option on `/audit`. That one is the contract `anchrd/signals` will
233
251
  // build on, cursor and direction included; a shared signature would put both readers on one
@@ -736,6 +754,28 @@ export function createHttp(deps) {
736
754
  });
737
755
  return context.json(await deps.flows.listRuns(asFlowActor(auth), input));
738
756
  });
757
+ /**
758
+ * The same list across every flow (#774) — "did anything run", which used to take one call per
759
+ * flow and therefore went unasked.
760
+ *
761
+ * ⚠️ It sits at the root beside the other three run routes rather than under a flow, and the
762
+ * reason is the one already written for them in `packages/api/CLAUDE.md`: reading, cancelling and
763
+ * completing a run do not need the flow, and a run outlives it. Naming no flow is exactly that
764
+ * case. One tool answers both addresses — `flow_run_list` with `flowId: null` — which is the same
765
+ * many-to-one the attachment routes have.
766
+ */
767
+ app.get("/flow-runs", async (context) => {
768
+ const auth = requireCapability(context, "flows", "read");
769
+ const url = new URL(context.req.url);
770
+ requireKnownQuery(url, ["failedOnly", "limit", "cursor"]);
771
+ const input = ListFlowRunsInput.parse({
772
+ flowId: null,
773
+ ...(url.searchParams.has("failedOnly") ? { failedOnly: true } : {}),
774
+ ...(url.searchParams.has("limit") ? { limit: Number(url.searchParams.get("limit")) } : {}),
775
+ ...(url.searchParams.has("cursor") ? { cursor: url.searchParams.get("cursor") } : {}),
776
+ });
777
+ return context.json(await deps.flows.listRuns(asFlowActor(auth), input));
778
+ });
739
779
  app.get("/flow-runs/:runId", async (context) => {
740
780
  const auth = requireCapability(context, "flows", "read");
741
781
  const input = GetFlowRunInput.parse({ runId: context.req.param("runId") });
@@ -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
  }