@anchrd/intel-api 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,35 @@
1
+ import { TableMediaType, } from "@anchrd/intel-contract";
2
+ import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
1
3
  import { IntelError } from "../shared/intel-error/intel-error.js";
4
+ import { documentLinkTargets } from "./document-links/document-links.js";
5
+ // `execute` is meaningful only for flows, and only a folder can hold a flow. A verb that cannot
6
+ // apply to a node is neither offered on it nor accepted for it (ADR-0004 §2). The answer lives here
7
+ // rather than in the screen so HTTP, MCP and the UI cannot disagree about it.
8
+ function applicableVerbs(kind) {
9
+ return kind === "folder" ? ["read", "write", "execute", "share"] : ["read", "write", "share"];
10
+ }
11
+ // ⚠️ The refusal has to be actionable without becoming a directory of the tree. Whoever holds
12
+ // `share` on one folder must not learn the titles of flows they may not see, so the ones they may
13
+ // see are named and the rest are only counted (ADR-0004 §3, and #17's review).
14
+ function callersDetail(callers) {
15
+ const named = callers.visible.map((title) => `“${title}”`).join(", ");
16
+ const rest = callers.hidden === 0
17
+ ? ""
18
+ : `${named ? " and " : ""}${callers.hidden} more flow${callers.hidden === 1 ? "" : "s"} you cannot see`;
19
+ return `Flows outside this folder call into it: ${named}${rest}. Change or unpublish them before narrowing the folder.`;
20
+ }
21
+ // The grantee as the ACL sees them, and as nothing else: an identity with no capability of its own,
22
+ // never `isAdmin`. It answers for Intel's resource ACLs only — whether Gate hands this person
23
+ // `intel/admin` is Gate's to know, so the warning below can be pessimistic and never permissive.
24
+ // A grant to an email address is judged as that address, which is what the grant will be attached
25
+ // to; a second grant the same person holds under their user ID is not folded in.
26
+ function asPrincipalActor(principal) {
27
+ if (principal.type === "user")
28
+ return { id: principal.id, email: "" };
29
+ if (principal.type === "email")
30
+ return { id: "", email: principal.email };
31
+ return { id: "", email: "" };
32
+ }
2
33
  function decodeBase64(value) {
3
34
  if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
4
35
  throw new IntelError(400, "attachment_invalid", "Attachment content is not valid base64");
@@ -48,6 +79,51 @@ export function createKnowledge(deps) {
48
79
  .sort((left, right) => right.score - left.score || right.freshness.localeCompare(left.freshness))
49
80
  .slice(0, limit);
50
81
  }
82
+ /**
83
+ * A table's body: every version's R2 object joined in sequence order (#40).
84
+ *
85
+ * ⚠️ This is the price of appending without reading. Each `append` writes one immutable object
86
+ * holding only the rows it added, so the write is O(new rows) and two concurrent appends cannot
87
+ * overwrite one another — but a read of a table with k appends costs k R2 gets. That is the
88
+ * trade the ticket asks for by name: the write side is the hot path an agent uses on a schedule,
89
+ * the read side is a person opening a grid or one indexing pass. The gets are issued together
90
+ * rather than in a chain so the cost is k requests, not k round trips; compacting old segments
91
+ * into one object is a later ticket, and it can happen without changing anything a caller sees
92
+ * because the version rows stay the history either way.
93
+ */
94
+ async function tableContent(node) {
95
+ if (node.currentVersionId === null)
96
+ return "";
97
+ // ⚠️ One statement for every segment key, never one per segment, and only the keys rather than
98
+ // the whole version rows. The number of D1 round trips a table costs must not grow with the
99
+ // number of times it has been appended to (#30).
100
+ const keys = await deps.repository.listVersionContentKeys(node.id);
101
+ const segments = await Promise.all(keys.map(async (key) => await deps.content.get(key)));
102
+ if (segments.some((segment) => segment === null)) {
103
+ throw new IntelError(500, "content_missing", "Version content is missing");
104
+ }
105
+ return segments.join("");
106
+ }
107
+ /**
108
+ * The column names, read from the first version alone.
109
+ *
110
+ * ⚠️ Deliberately not `tableContent`, and deliberately not the version list either. Checking an
111
+ * append against the header runs on every append, so it must cost the same on a table of ten rows
112
+ * and on one of ten thousand: one statement that returns one key, and one small R2 read. Reading
113
+ * every version row to look at the first would be a cost that grows with the history — one query,
114
+ * but more of it every time (#30, #40).
115
+ */
116
+ async function tableHeader(node) {
117
+ if (node.currentVersionId === null)
118
+ return null;
119
+ const key = await deps.repository.firstVersionContentKey(node.id);
120
+ if (key === null)
121
+ return null;
122
+ const body = await deps.content.get(key);
123
+ if (body === null)
124
+ throw new IntelError(500, "content_missing", "Version content is missing");
125
+ return parseCsv(body)[0] ?? null;
126
+ }
51
127
  async function getDocument(node) {
52
128
  if (node.currentVersionId === null)
53
129
  return { node, version: null, content: null };
@@ -56,11 +132,129 @@ export function createKnowledge(deps) {
56
132
  throw new IntelError(500, "version_missing", "Current version is missing");
57
133
  if (node.kind === "attachment")
58
134
  return { node, version, content: null };
135
+ // A table answers with the whole CSV, so downloading, citing and reading it over MCP all get
136
+ // the same bytes a person sees in the grid — the format is the export (#40).
137
+ if (node.kind === "table")
138
+ return { node, version, content: await tableContent(node) };
59
139
  const content = await deps.content.get(version.contentKey);
60
140
  if (content === null)
61
141
  throw new IntelError(500, "content_missing", "Version content is missing");
62
142
  return { node, version, content };
63
143
  }
144
+ async function tableOf(node) {
145
+ const [header = [], ...rows] = parseCsv(await tableContent(node));
146
+ return { node, columns: header, rows, versionId: node.currentVersionId };
147
+ }
148
+ /**
149
+ * One immutable object holding only what this write adds, plus the version row that orders it.
150
+ *
151
+ * ⚠️ The R2 object is written before the version row and is deleted again if the row does not
152
+ * land, exactly as `save` does it. An orphaned object is invisible; a version row pointing at
153
+ * nothing is a table that cannot be read at all.
154
+ */
155
+ async function writeTableSegment(actor, node, body, idempotencyKey) {
156
+ const versionId = deps.id();
157
+ const contentKey = `knowledge/${node.id}/versions/${versionId}`;
158
+ await deps.content.put(contentKey, body, TableMediaType);
159
+ try {
160
+ return await deps.repository.appendTableVersion({
161
+ version: {
162
+ id: versionId,
163
+ nodeId: node.id,
164
+ contentKey,
165
+ mediaType: TableMediaType,
166
+ contentHash: await deps.hash(body),
167
+ size: new TextEncoder().encode(body).byteLength,
168
+ createdBy: actor.id,
169
+ createdAt: deps.now().toISOString(),
170
+ },
171
+ actorId: actor.id,
172
+ idempotencyKey,
173
+ auditId: deps.id(),
174
+ });
175
+ }
176
+ catch (error) {
177
+ await deps.content.delete(contentKey).catch(() => undefined);
178
+ throw error;
179
+ }
180
+ }
181
+ async function requireTable(actor, nodeId) {
182
+ const node = await requireVisible(actor, nodeId);
183
+ if (node.kind !== "table") {
184
+ throw new IntelError(409, "not_a_table", "Only tables accept rows");
185
+ }
186
+ if (!(await deps.repository.can(actor, node.id, "write"))) {
187
+ throw new IntelError(403, "knowledge_forbidden", "Table cannot be edited");
188
+ }
189
+ return node;
190
+ }
191
+ /**
192
+ * What the grant just written does not cover: the documents the flows in this folder read that
193
+ * the new principal still cannot.
194
+ *
195
+ * ⚠️ It warns and never blocks. A Knowledge reference across the folder edge is a possible
196
+ * failure, not a way around permissions — that distinction is the whole of ADR-0004 §4, and a
197
+ * block here would force everyone using one central policy document to duplicate it.
198
+ *
199
+ * ⚠️ Which documents may be named is `getVisible`, the lookup every other Knowledge read goes
200
+ * through, and never a second rule written for a message: the ones this actor may see are named,
201
+ * the rest are counted. That is exactly where #17 and #19 went wrong in review.
202
+ *
203
+ * The flow side is filtered by the flow list's own predicate as well. It changes nothing today —
204
+ * sharing needs `read` on the folder and `read` inherits over the whole subtree, so every flow in
205
+ * it is already visible — but the verbs are independent by decision (ADR-0004 §2), and the day
206
+ * `share` stops implying `read` this must not be the place that quietly starts leaking.
207
+ *
208
+ * It does say one thing about the grantee: that a named document is not readable for them. That
209
+ * is the consequence of this actor's own decision, on their own folder, at the moment they make
210
+ * it, and it is the smallest answer that lets them make it — the alternative is the flow failing
211
+ * for someone else next week with nobody able to say why. It never enumerates the principal's
212
+ * access in general and never leaves the documents these flows actually name.
213
+ */
214
+ async function unreadableForPrincipal(actor, folderId, principal) {
215
+ const grantee = asPrincipalActor(principal);
216
+ const titles = [];
217
+ let hidden = 0;
218
+ for (const resourceId of await deps.flowKnowledgeReferences(actor, folderId)) {
219
+ if (await deps.repository.can(grantee, resourceId, "read"))
220
+ continue;
221
+ const node = await deps.repository.getVisible(actor, resourceId);
222
+ if (node)
223
+ titles.push(node.title);
224
+ else
225
+ hidden += 1;
226
+ }
227
+ return { titles, hidden };
228
+ }
229
+ /**
230
+ * The graph, brought in line with what the saved document actually says (#41).
231
+ *
232
+ * A text link is the relationship — there is no second way to make one any more — so the links
233
+ * of a document are rewritten every time it is saved: what is no longer written is no longer
234
+ * there, and what was added is.
235
+ *
236
+ * ⚠️ Only targets this actor may see become links. The author can only insert what the picker
237
+ * offers them, but `knowledge_save` takes any content over MCP, and an unfiltered write would
238
+ * turn the graph into a place where the existence of an unreachable document can be confirmed by
239
+ * anyone who guesses its ID. The filter is `resolveVisibleTitles`, the same lookup the reader's
240
+ * side goes through — one rule, not two.
241
+ *
242
+ * ⚠️ Rows made in the removed dialog are `manual` and are left alone. Saving a document must not
243
+ * silently delete a relationship somebody entered before there was another way to enter one.
244
+ */
245
+ async function reconcileTextLinks(actor, sourceNodeId, mediaType, content) {
246
+ const written = documentLinkTargets(mediaType, content).filter((id) => id !== sourceNodeId);
247
+ const visible = written.length === 0
248
+ ? []
249
+ : (await deps.repository.resolveVisibleTitles(actor, written)).map((entry) => entry.nodeId);
250
+ await deps.repository.replaceTextLinks({
251
+ sourceNodeId,
252
+ links: visible.map((targetNodeId) => ({ id: deps.id(), targetNodeId })),
253
+ actorId: actor.id,
254
+ auditId: deps.id(),
255
+ occurredAt: deps.now().toISOString(),
256
+ });
257
+ }
64
258
  async function requireVisible(actor, nodeId) {
65
259
  const node = await deps.repository.getVisible(actor, nodeId);
66
260
  if (!node)
@@ -94,6 +288,12 @@ export function createKnowledge(deps) {
94
288
  async list(actor, input) {
95
289
  return { items: await deps.repository.listVisible(actor, input) };
96
290
  },
291
+ // The same level under a bound, for the one caller that draws a bounded picture of it. It goes
292
+ // through the same predicate as `list`, so what is drawn is a prefix of what is listed and never
293
+ // a different selection (#30).
294
+ async childrenBounded(actor, input) {
295
+ return await deps.repository.listVisibleBounded(actor, input);
296
+ },
97
297
  async get(actor, nodeId) {
98
298
  return await getDocument(await requireVisible(actor, nodeId));
99
299
  },
@@ -106,13 +306,13 @@ export function createKnowledge(deps) {
106
306
  return "missing";
107
307
  if (folder.kind !== "folder")
108
308
  return "not-a-folder";
109
- return (await deps.repository.canEdit(actor, folder.id)) ? "ok" : "forbidden";
309
+ return (await deps.repository.can(actor, folder.id, "write")) ? "ok" : "forbidden";
110
310
  },
111
311
  async create(actor, input) {
112
312
  const existingId = await deps.repository.findIdempotentNode(actor.id, "knowledge.create", input.idempotencyKey);
113
313
  if (existingId)
114
314
  return await requireVisible(actor, existingId);
115
- if (input.parentId !== null && !(await deps.repository.canEdit(actor, input.parentId))) {
315
+ if (input.parentId !== null && !(await deps.repository.can(actor, input.parentId, "write"))) {
116
316
  throw new IntelError(403, "knowledge_forbidden", "Parent folder cannot be edited");
117
317
  }
118
318
  const timestamp = deps.now().toISOString();
@@ -146,7 +346,7 @@ export function createKnowledge(deps) {
146
346
  if (node.kind !== "document") {
147
347
  throw new IntelError(409, "document_content_required", "Only documents accept editor content versions");
148
348
  }
149
- if (!(await deps.repository.canEdit(actor, node.id))) {
349
+ if (!(await deps.repository.can(actor, node.id, "write"))) {
150
350
  throw new IntelError(403, "knowledge_forbidden", "Knowledge cannot be edited");
151
351
  }
152
352
  if (node.currentVersionId !== input.baseVersionId) {
@@ -189,6 +389,7 @@ export function createKnowledge(deps) {
189
389
  throw new IntelError(409, "version_conflict", "A newer version already exists");
190
390
  }
191
391
  const updated = await requireVisible(actor, node.id);
392
+ await reconcileTextLinks(actor, node.id, input.mediaType, input.content);
192
393
  await deps.indexing.enqueue(version.id);
193
394
  return { node: updated, version, content: input.content };
194
395
  },
@@ -203,7 +404,7 @@ export function createKnowledge(deps) {
203
404
  if (node.kind !== "attachment") {
204
405
  throw new IntelError(409, "not_an_attachment", "Only attachment nodes accept file uploads");
205
406
  }
206
- if (!(await deps.repository.canEdit(actor, node.id))) {
407
+ if (!(await deps.repository.can(actor, node.id, "write"))) {
207
408
  throw new IntelError(403, "knowledge_forbidden", "Attachment cannot be edited");
208
409
  }
209
410
  if (node.currentVersionId !== input.baseVersionId) {
@@ -256,13 +457,78 @@ export function createKnowledge(deps) {
256
457
  throw new IntelError(500, "content_missing", "Attachment is missing");
257
458
  return { attachment: metadata, body };
258
459
  },
460
+ async getTable(actor, nodeId) {
461
+ const node = await requireVisible(actor, nodeId);
462
+ if (node.kind !== "table") {
463
+ throw new IntelError(404, "table_not_found", "Table was not found");
464
+ }
465
+ return await tableOf(node);
466
+ },
467
+ /**
468
+ * Writes the header, once. The columns are the contract every later append is measured against
469
+ * (#40), so a second definition is refused rather than merged: a table whose header changed
470
+ * would reinterpret every row already appended under the old one, silently and irreversibly.
471
+ */
472
+ async defineTable(actor, input) {
473
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "knowledge.append", input.idempotencyKey);
474
+ const node = await requireTable(actor, input.nodeId);
475
+ if (replayedId)
476
+ return await tableOf(node);
477
+ if (node.currentVersionId !== null) {
478
+ throw new IntelError(409, "table_already_defined", "This table already has a header");
479
+ }
480
+ const body = encodeCsv([input.columns]);
481
+ const version = await writeTableSegment(actor, node, body, input.idempotencyKey);
482
+ await deps.indexing.enqueue(version.id);
483
+ return await tableOf({ ...node, currentVersionId: version.id });
484
+ },
485
+ /**
486
+ * Rows at the end, and nothing else touched.
487
+ *
488
+ * ⚠️ No `baseVersionId` and no conflict: each append writes its own immutable object and its
489
+ * own version row, so two appends that arrive together both land and neither can overwrite the
490
+ * other. This is the whole difference to `save`, which replaces content and therefore has to
491
+ * know what it replaces.
492
+ */
493
+ async appendTableRows(actor, input) {
494
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "knowledge.append", input.idempotencyKey);
495
+ const node = await requireTable(actor, input.nodeId);
496
+ if (replayedId) {
497
+ const replayed = await deps.repository.getVersion(replayedId);
498
+ if (replayed) {
499
+ return {
500
+ node: await requireVisible(actor, node.id),
501
+ version: replayed,
502
+ appended: input.rows.length,
503
+ };
504
+ }
505
+ }
506
+ const header = await tableHeader(node);
507
+ if (header === null) {
508
+ throw new IntelError(409, "table_undefined", "This table has no header yet; define its columns before appending");
509
+ }
510
+ // ⚠️ Refused, never padded and never truncated. A row that does not fit the header is a
511
+ // caller that believes the table has a different shape, and quietly filling the gap would
512
+ // store that misunderstanding as data nobody can tell apart from the real thing afterwards.
513
+ const wrong = input.rows.findIndex((row) => row.length !== header.length);
514
+ if (wrong !== -1) {
515
+ throw new IntelError(400, "table_row_shape", `Row ${wrong + 1} has ${input.rows[wrong]?.length ?? 0} cells but the table has ${header.length} columns: ${header.join(", ")}`);
516
+ }
517
+ const version = await writeTableSegment(actor, node, encodeCsv(input.rows), input.idempotencyKey);
518
+ await deps.indexing.enqueue(version.id);
519
+ return {
520
+ node: await requireVisible(actor, node.id),
521
+ version,
522
+ appended: input.rows.length,
523
+ };
524
+ },
259
525
  async listVersions(actor, nodeId) {
260
526
  await requireVisible(actor, nodeId);
261
527
  return { items: await deps.repository.listVersions(nodeId) };
262
528
  },
263
529
  async update(actor, input) {
264
530
  const current = await requireVisible(actor, input.nodeId);
265
- if (!(await deps.repository.canEdit(actor, current.id))) {
531
+ if (!(await deps.repository.can(actor, current.id, "write"))) {
266
532
  throw new IntelError(403, "knowledge_forbidden", "Knowledge cannot be edited");
267
533
  }
268
534
  const replayedId = await deps.repository.findIdempotentNode(actor.id, "knowledge.update", input.idempotencyKey);
@@ -276,7 +542,7 @@ export function createKnowledge(deps) {
276
542
  if (parent.kind !== "folder") {
277
543
  throw new IntelError(409, "parent_not_folder", "Knowledge parent must be a folder");
278
544
  }
279
- if (!(await deps.repository.canEdit(actor, parent.id))) {
545
+ if (!(await deps.repository.can(actor, parent.id, "write"))) {
280
546
  throw new IntelError(403, "knowledge_forbidden", "Destination folder cannot be edited");
281
547
  }
282
548
  }
@@ -307,7 +573,7 @@ export function createKnowledge(deps) {
307
573
  },
308
574
  async archive(actor, input) {
309
575
  const current = await requireVisible(actor, input.nodeId);
310
- if (!(await deps.repository.canEdit(actor, current.id))) {
576
+ if (!(await deps.repository.can(actor, current.id, "write"))) {
311
577
  throw new IntelError(403, "knowledge_forbidden", "Knowledge cannot be edited");
312
578
  }
313
579
  const replayedId = await deps.repository.findIdempotentNode(actor.id, "knowledge.archive", input.idempotencyKey);
@@ -329,94 +595,81 @@ export function createKnowledge(deps) {
329
595
  return updated;
330
596
  },
331
597
  async listGrants(actor, resourceId) {
332
- await requireVisible(actor, resourceId);
333
- if (!(await deps.repository.canManage(actor, resourceId))) {
598
+ const node = await requireVisible(actor, resourceId);
599
+ if (!(await deps.repository.can(actor, resourceId, "share"))) {
334
600
  throw new IntelError(403, "knowledge_forbidden", "Knowledge sharing cannot be managed");
335
601
  }
336
- return { items: await deps.repository.listGrants(resourceId) };
602
+ return {
603
+ resourceId: node.id,
604
+ applicableVerbs: applicableVerbs(node.kind),
605
+ items: await deps.repository.listGrants(resourceId),
606
+ };
337
607
  },
338
608
  async listLinks(actor, nodeId) {
339
609
  await requireVisible(actor, nodeId);
340
610
  return { items: await deps.repository.listLinksVisible(actor, nodeId) };
341
611
  },
342
- async createLink(actor, input) {
343
- await requireVisible(actor, input.sourceNodeId);
344
- await requireVisible(actor, input.targetNodeId);
345
- if (!(await deps.repository.canEdit(actor, input.sourceNodeId))) {
346
- throw new IntelError(403, "knowledge_forbidden", "Knowledge links cannot be edited");
347
- }
348
- const replayedId = await deps.repository.findIdempotentNode(actor.id, "knowledge.link", input.idempotencyKey);
349
- if (replayedId) {
350
- const replayed = await deps.repository.getLinkVisible(actor, replayedId);
351
- if (replayed)
352
- return replayed;
353
- }
354
- const existing = await deps.repository.findLinkVisible(actor, input);
355
- if (existing)
356
- return existing;
357
- const createdAt = deps.now().toISOString();
358
- return await deps.repository.insertLink({
359
- link: {
360
- id: deps.id(),
361
- sourceNodeId: input.sourceNodeId,
362
- targetNodeId: input.targetNodeId,
363
- relation: input.relation,
364
- label: input.label,
365
- createdBy: actor.id,
366
- createdAt,
367
- },
368
- actorId: actor.id,
369
- idempotencyKey: input.idempotencyKey,
370
- auditId: deps.id(),
371
- });
372
- },
373
- async deleteLink(actor, input) {
374
- await requireVisible(actor, input.sourceNodeId);
375
- if (!(await deps.repository.canEdit(actor, input.sourceNodeId))) {
376
- throw new IntelError(403, "knowledge_forbidden", "Knowledge links cannot be edited");
377
- }
378
- const replayed = await deps.repository.findIdempotentNode(actor.id, "knowledge.unlink", input.idempotencyKey);
379
- if (replayed)
380
- return { deleted: replayed.startsWith("1:") };
381
- const link = await deps.repository.getLinkVisible(actor, input.linkId);
382
- if (!link || link.sourceNodeId !== input.sourceNodeId) {
383
- throw new IntelError(404, "knowledge_link_not_found", "Knowledge link was not found");
384
- }
385
- return {
386
- deleted: await deps.repository.deleteLink({
387
- sourceNodeId: input.sourceNodeId,
388
- linkId: input.linkId,
389
- actorId: actor.id,
390
- idempotencyKey: input.idempotencyKey,
391
- auditId: deps.id(),
392
- occurredAt: deps.now().toISOString(),
393
- }),
394
- };
612
+ /**
613
+ * The titles of linked documents, for the reader who is looking at the text (#41).
614
+ *
615
+ * ⚠️ The one place a document link gets a name, and it answers with what this reader may see —
616
+ * `getVisible`, the same predicate every other Knowledge read goes through — never with a
617
+ * lookup written specially for a label. What is missing from the answer is missing for two
618
+ * reasons that must stay indistinguishable: the target is gone, or it is not theirs to see. A
619
+ * shape that told them apart would let a document confirm the existence of one they may not
620
+ * reach, and that is what the link does not get to say.
621
+ *
622
+ * ⚠️ Archived targets are absent too. A link to a deleted document has to break visibly rather
623
+ * than point quietly at nothing, and "absent" is what the reader's side draws as broken.
624
+ */
625
+ async resolveLinks(actor, input) {
626
+ return { items: await deps.repository.resolveVisibleTitles(actor, input.nodeIds) };
395
627
  },
396
628
  async graph(actor, input) {
397
629
  return await deps.repository.graphVisible(actor, input);
398
630
  },
631
+ // ⚠️ The one shape of this question that answers `null` instead of throwing, and the only one a
632
+ // graph may be built from. `get` loads the body and turns a refusal into a 404 the caller has to
633
+ // catch; here "you cannot reach it" is a value, so a drawing can leave a node out rather than
634
+ // deciding what to do with an exception halfway through (#19).
635
+ //
636
+ // ⚠️ Visibility and nothing else — deliberately not "and not archived". A run authorizes its
637
+ // Knowledge steps through this same answer (#20), and archiving a document must not start
638
+ // refusing steps under a message that names the wrong reason. Callers that must not *draw* an
639
+ // archived node say so where they draw, the way the callee side already does.
640
+ async visibleNode(actor, nodeId) {
641
+ return await deps.repository.getVisible(actor, nodeId);
642
+ },
399
643
  async share(actor, input) {
400
- await requireVisible(actor, input.resourceId);
401
- if (!(await deps.repository.canManage(actor, input.resourceId))) {
644
+ const node = await requireVisible(actor, input.resourceId);
645
+ if (!(await deps.repository.can(actor, input.resourceId, "share"))) {
402
646
  throw new IntelError(403, "knowledge_forbidden", "Knowledge sharing cannot be managed");
403
647
  }
404
- const replayedId = await deps.repository.findIdempotentNode(actor.id, "knowledge.share", input.idempotencyKey);
405
- if (replayedId) {
406
- const replayed = (await deps.repository.listGrants(input.resourceId)).find((grant) => grant.id === replayedId);
407
- if (replayed)
408
- return replayed;
648
+ if (!applicableVerbs(node.kind).includes(input.verb)) {
649
+ throw new IntelError(409, "verb_not_applicable", `A ${node.kind} cannot be granted ${input.verb}`);
409
650
  }
651
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, "knowledge.share", input.idempotencyKey);
410
652
  const principal = input.principal.type === "email"
411
653
  ? { type: "email", email: input.principal.email.toLowerCase() }
412
654
  : input.principal;
655
+ if (replayedId) {
656
+ const replayed = (await deps.repository.listGrants(input.resourceId)).find((grant) => grant.id === replayedId);
657
+ // A replay describes the same access as the first attempt did, so the warning is asked
658
+ // again rather than remembered: whether a document is readable can have changed since.
659
+ if (replayed) {
660
+ return {
661
+ grant: replayed,
662
+ unreadable: await unreadableForPrincipal(actor, node.id, replayed.principal),
663
+ };
664
+ }
665
+ }
413
666
  const timestamp = deps.now().toISOString();
414
- return await deps.repository.setGrant({
667
+ const grant = await deps.repository.setGrant({
415
668
  grant: {
416
669
  id: deps.id(),
417
670
  resourceId: input.resourceId,
418
671
  principal,
419
- role: input.role,
672
+ verb: input.verb,
420
673
  expiresAt: input.expiresAt,
421
674
  createdBy: actor.id,
422
675
  createdAt: timestamp,
@@ -425,15 +678,35 @@ export function createKnowledge(deps) {
425
678
  idempotencyKey: input.idempotencyKey,
426
679
  auditId: deps.id(),
427
680
  });
681
+ // ⚠️ After the grant is written, never before. The answer has to describe the access that is
682
+ // now in force — sharing `read` on this folder is exactly what makes the documents inside it
683
+ // readable, and a warning computed a moment earlier would name them all.
684
+ return { grant, unreadable: await unreadableForPrincipal(actor, node.id, principal) };
428
685
  },
429
686
  async revokeGrant(actor, input) {
430
687
  await requireVisible(actor, input.resourceId);
431
- if (!(await deps.repository.canManage(actor, input.resourceId))) {
688
+ if (!(await deps.repository.can(actor, input.resourceId, "share"))) {
432
689
  throw new IntelError(403, "knowledge_forbidden", "Knowledge sharing cannot be managed");
433
690
  }
434
691
  const replayed = await deps.repository.findIdempotentRevocation(actor.id, input.idempotencyKey);
435
692
  if (replayed !== null)
436
693
  return { revoked: replayed };
694
+ // ⚠️ What makes a folder a library is `execute` for the whole organization: that is the one
695
+ // grant ADR-0004 §3 lets a flow call across a folder edge for. Taking it away while calls
696
+ // reach in from outside would leave those flows published and unrunnable, so the attempt names
697
+ // the callers instead of breaking them silently. A grant to one principal beside it narrows
698
+ // nothing while the organization-wide one stands, so only that one is guarded — and only when
699
+ // no second organization-wide `execute` above it keeps the reach alive, because then this
700
+ // revocation narrows nothing either and there is nothing to refuse.
701
+ const revoked = (await deps.repository.listGrants(input.resourceId)).find((grant) => grant.id === input.grantId);
702
+ if (revoked?.verb === "execute" &&
703
+ revoked.principal.type === "organization" &&
704
+ !(await deps.repository.organizationExecuteReaches(input.resourceId, input.grantId))) {
705
+ const callers = await deps.externalFlowCallers(actor, input.resourceId);
706
+ if (callers.visible.length || callers.hidden) {
707
+ throw new IntelError(409, "folder_execute_in_use", callersDetail(callers));
708
+ }
709
+ }
437
710
  return {
438
711
  revoked: await deps.repository.revokeGrant({
439
712
  resourceId: input.resourceId,
@@ -470,7 +743,7 @@ export function createKnowledge(deps) {
470
743
  }
471
744
  },
472
745
  async reindex(actor) {
473
- if (actor.canReindex !== true) {
746
+ if (actor.isAdmin !== true) {
474
747
  throw new IntelError(403, "knowledge_reindex_forbidden", "Reindex permission is required");
475
748
  }
476
749
  let queued = 0;