@bevel-software/platform-core-backend 0.28.1 → 0.30.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.
Files changed (274) hide show
  1. package/agent-guide/access-control.md +15 -0
  2. package/agent-guide/skills.md +8 -0
  3. package/dist/__tests__/test-repositories.d.ts +3 -0
  4. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  5. package/dist/__tests__/test-repositories.js +18 -0
  6. package/dist/__tests__/test-repositories.js.map +1 -0
  7. package/dist/core/catalog-revision.d.ts.map +1 -1
  8. package/dist/core/catalog-revision.js +4 -2
  9. package/dist/core/catalog-revision.js.map +1 -1
  10. package/dist/core/compose-embed.d.ts +35 -0
  11. package/dist/core/compose-embed.d.ts.map +1 -0
  12. package/dist/core/compose-embed.js +14 -0
  13. package/dist/core/compose-embed.js.map +1 -0
  14. package/dist/core/core-ports.d.ts +10 -0
  15. package/dist/core/core-ports.d.ts.map +1 -1
  16. package/dist/core/core-ports.js.map +1 -1
  17. package/dist/core/create-core-server.d.ts.map +1 -1
  18. package/dist/core/create-core-server.js +8 -2
  19. package/dist/core/create-core-server.js.map +1 -1
  20. package/dist/core/create-core-services.d.ts +4 -1
  21. package/dist/core/create-core-services.d.ts.map +1 -1
  22. package/dist/core/create-core-services.js +57 -18
  23. package/dist/core/create-core-services.js.map +1 -1
  24. package/dist/core/lifecycle.d.ts +19 -6
  25. package/dist/core/lifecycle.d.ts.map +1 -1
  26. package/dist/core/lifecycle.js +17 -6
  27. package/dist/core/lifecycle.js.map +1 -1
  28. package/dist/modules/access/access-control.interface.d.ts +20 -0
  29. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  30. package/dist/modules/access/access-control.service.d.ts +68 -15
  31. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  32. package/dist/modules/access/access-control.service.js +282 -58
  33. package/dist/modules/access/access-control.service.js.map +1 -1
  34. package/dist/modules/access/access.routes.d.ts.map +1 -1
  35. package/dist/modules/access/access.routes.js +20 -6
  36. package/dist/modules/access/access.routes.js.map +1 -1
  37. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  38. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  39. package/dist/modules/auth/index.d.ts +15 -0
  40. package/dist/modules/auth/index.d.ts.map +1 -0
  41. package/dist/modules/auth/index.js +20 -0
  42. package/dist/modules/auth/index.js.map +1 -0
  43. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  44. package/dist/modules/diff/diff.service.js +7 -1
  45. package/dist/modules/diff/diff.service.js.map +1 -1
  46. package/dist/modules/embed/embed.interface.d.ts +22 -0
  47. package/dist/modules/embed/embed.interface.d.ts.map +1 -1
  48. package/dist/modules/embed/embed.service.d.ts +28 -14
  49. package/dist/modules/embed/embed.service.d.ts.map +1 -1
  50. package/dist/modules/embed/embed.service.js +38 -17
  51. package/dist/modules/embed/embed.service.js.map +1 -1
  52. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  53. package/dist/modules/embed/embed.tools.js +10 -2
  54. package/dist/modules/embed/embed.tools.js.map +1 -1
  55. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  56. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  57. package/dist/modules/kb-fs/git-dir.js +58 -0
  58. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  59. package/dist/modules/kb-fs/index.d.ts +9 -0
  60. package/dist/modules/kb-fs/index.d.ts.map +1 -0
  61. package/dist/modules/kb-fs/index.js +3 -0
  62. package/dist/modules/kb-fs/index.js.map +1 -0
  63. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  64. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  65. package/dist/modules/kb-fs/ref-files.js +249 -0
  66. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  67. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
  68. package/dist/modules/plugins/join-request-jobs.service.js +43 -8
  69. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
  70. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  71. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  72. package/dist/modules/plugins/teams.routes.js +9 -1
  73. package/dist/modules/plugins/teams.routes.js.map +1 -1
  74. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  75. package/dist/modules/skills/skills.tools.d.ts +8 -1
  76. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  77. package/dist/modules/skills/skills.tools.js +115 -11
  78. package/dist/modules/skills/skills.tools.js.map +1 -1
  79. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  80. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  81. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  82. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  83. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  84. package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
  85. package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
  86. package/dist/modules/tool-helpers/default-branch-write.js +63 -0
  87. package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
  88. package/dist/modules/tool-helpers/index.d.ts +1 -0
  89. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  90. package/dist/modules/tool-helpers/index.js +1 -0
  91. package/dist/modules/tool-helpers/index.js.map +1 -1
  92. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  93. package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
  94. package/dist/modules/workflow/file-lock.service.d.ts +27 -5
  95. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  96. package/dist/modules/workflow/file-lock.service.js +70 -18
  97. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  98. package/dist/modules/workflow/git/git.service.d.ts +42 -0
  99. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  100. package/dist/modules/workflow/git/git.service.js +201 -24
  101. package/dist/modules/workflow/git/git.service.js.map +1 -1
  102. package/dist/modules/workflow/index.d.ts +8 -0
  103. package/dist/modules/workflow/index.d.ts.map +1 -0
  104. package/dist/modules/workflow/index.js +8 -0
  105. package/dist/modules/workflow/index.js.map +1 -0
  106. package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
  107. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  108. package/dist/modules/workflow/pending-commits.service.js +4 -24
  109. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  110. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  111. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  112. package/dist/modules/workflow/workflow.routes.js +20 -1
  113. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  114. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/workflow.service.js +5 -3
  116. package/dist/modules/workflow/workflow.service.js.map +1 -1
  117. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  118. package/dist/modules/workspace/agent-access.gate.js +2 -2
  119. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  120. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  121. package/dist/modules/workspace/bounded-matcher.js +261 -0
  122. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  123. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  124. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  125. package/dist/modules/workspace/fetch-instant.js +13 -0
  126. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  127. package/dist/modules/workspace/index.d.ts +17 -0
  128. package/dist/modules/workspace/index.d.ts.map +1 -0
  129. package/dist/modules/workspace/index.js +16 -0
  130. package/dist/modules/workspace/index.js.map +1 -0
  131. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  132. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  133. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  134. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  135. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  136. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  137. package/dist/modules/workspace/kb-repository.js +811 -0
  138. package/dist/modules/workspace/kb-repository.js.map +1 -0
  139. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  140. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  141. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  142. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  143. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  144. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  145. package/dist/modules/workspace/routine-write-policy.js +0 -5
  146. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  147. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  148. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  149. package/dist/modules/workspace/set-aside-clone.js +139 -0
  150. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  151. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  152. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  153. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  154. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  155. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  156. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  157. package/dist/modules/workspace/workspace.service.js +240 -161
  158. package/dist/modules/workspace/workspace.service.js.map +1 -1
  159. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  160. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  161. package/dist/modules/workspace/workspace.tools.js +268 -356
  162. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  163. package/dist/shared/workspace-id.d.ts +17 -0
  164. package/dist/shared/workspace-id.d.ts.map +1 -1
  165. package/dist/shared/workspace-id.js +24 -0
  166. package/dist/shared/workspace-id.js.map +1 -1
  167. package/dist/test-setup.js +4 -0
  168. package/dist/test-setup.js.map +1 -1
  169. package/mcp-app/page.html +142 -8
  170. package/package.json +3 -3
  171. package/src/__tests__/test-repositories.ts +22 -0
  172. package/src/__tests__/workspace-service.ts +69 -0
  173. package/src/core/__tests__/compose-embed.test.ts +65 -0
  174. package/src/core/__tests__/lifecycle.test.ts +45 -26
  175. package/src/core/catalog-revision.ts +4 -2
  176. package/src/core/compose-embed.ts +50 -0
  177. package/src/core/core-ports.ts +10 -0
  178. package/src/core/create-core-server.ts +8 -2
  179. package/src/core/create-core-services.ts +63 -24
  180. package/src/core/lifecycle.ts +34 -12
  181. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +443 -25
  182. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  183. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  184. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  185. package/src/modules/access/access-control.interface.ts +22 -0
  186. package/src/modules/access/access-control.service.ts +302 -58
  187. package/src/modules/access/access.routes.ts +22 -6
  188. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  189. package/src/modules/auth/index.ts +29 -0
  190. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  191. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  192. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  193. package/src/modules/diff/diff.service.ts +6 -4
  194. package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
  195. package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
  196. package/src/modules/embed/embed.interface.ts +24 -0
  197. package/src/modules/embed/embed.service.ts +44 -19
  198. package/src/modules/embed/embed.tools.ts +10 -2
  199. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  200. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  201. package/src/modules/kb-fs/git-dir.ts +53 -0
  202. package/src/modules/kb-fs/index.ts +8 -0
  203. package/src/modules/kb-fs/ref-files.ts +279 -0
  204. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
  205. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  206. package/src/modules/plugins/join-request-jobs.service.ts +45 -8
  207. package/src/modules/plugins/teams.routes.ts +14 -1
  208. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  209. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  210. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
  211. package/src/modules/skills/skills.contract.ts +4 -0
  212. package/src/modules/skills/skills.tools.ts +120 -10
  213. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  214. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  215. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  216. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
  217. package/src/modules/tool-helpers/default-branch-write.ts +90 -0
  218. package/src/modules/tool-helpers/index.ts +5 -0
  219. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  220. package/src/modules/tool-helpers/tool.contract.ts +2 -2
  221. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +42 -3
  222. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
  223. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
  224. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
  225. package/src/modules/workflow/file-lock.service.ts +79 -18
  226. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  227. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
  228. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  229. package/src/modules/workflow/git/git.service.ts +207 -22
  230. package/src/modules/workflow/index.ts +7 -0
  231. package/src/modules/workflow/pending-commits.service.ts +4 -23
  232. package/src/modules/workflow/workflow-hooks.ts +1 -1
  233. package/src/modules/workflow/workflow.routes.ts +24 -1
  234. package/src/modules/workflow/workflow.service.ts +3 -2
  235. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  236. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  237. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  238. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  239. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  240. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  241. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  242. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  243. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  244. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  245. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  246. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  247. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  248. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  249. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  250. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  251. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  252. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  253. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  254. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  255. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  256. package/src/modules/workspace/__tests__/workspace.tools.test.ts +174 -309
  257. package/src/modules/workspace/agent-access.gate.ts +2 -2
  258. package/src/modules/workspace/bounded-matcher.ts +328 -0
  259. package/src/modules/workspace/fetch-instant.ts +13 -0
  260. package/src/modules/workspace/index.ts +26 -0
  261. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  262. package/src/modules/workspace/kb-repository.ts +844 -0
  263. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  264. package/src/modules/workspace/routine-write-policy.ts +0 -15
  265. package/src/modules/workspace/set-aside-clone.ts +140 -0
  266. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  267. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  268. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  269. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  270. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  271. package/src/modules/workspace/workspace.service.ts +247 -169
  272. package/src/modules/workspace/workspace.tools.ts +299 -385
  273. package/src/shared/workspace-id.ts +24 -0
  274. package/src/test-setup.ts +5 -0
@@ -8,6 +8,7 @@ import { isAbsence, type ITreeWalker, type WalkListener } from '../../shared/fs.
8
8
  import { assertOneSpecPerLine, isGitTimeout, type IGitRunner } from '../../shared/git.contract.js';
9
9
  import { hashEmail } from '../../shared/email-identity.js';
10
10
  import { NodeGitRunner } from '../workflow/git/node-git-runner.js';
11
+ import { readRevSha } from '../kb-fs/index.js';
11
12
  import type { WorkspaceService } from '../workspace/workspace.service.js';
12
13
  import type {
13
14
  IAccessControl,
@@ -66,19 +67,35 @@ import {
66
67
  DIRECTORY_SYNC_BOT_NAME,
67
68
  } from './directory-sync-bot.js';
68
69
 
70
+ /** A full object id, which is what every at-ref gate resolves its ref to before reading. */
71
+ function isCommitSha(ref: string): boolean {
72
+ return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(ref);
73
+ }
74
+
75
+ /**
76
+ * What one spec of a batched read came back as: a blob's text, nothing at
77
+ * that path, or an object that is not a file (a directory, say). The last
78
+ * two are told apart on purpose: for the model at a commit, a path that is
79
+ * there but is not a file is not "absent", it is config the model cannot be
80
+ * read from.
81
+ */
82
+ type CatFileOutcome =
83
+ | { kind: 'blob'; text: string }
84
+ | { kind: 'missing' }
85
+ | { kind: 'non-blob'; type: string };
86
+
69
87
  /**
70
88
  * Read many objects from a git repo in ONE `git cat-file --batch` process.
71
89
  * `specs` are `<ref>:<path>` lines; the result array is index-aligned with
72
- * them — the blob's text, or null when the spec is missing/ambiguous at the
73
- * ref or resolves to a non-blob (e.g. a directory). Output protocol per
74
- * request: `<oid> <type> <size>\n<size bytes>\n`, or `<spec> missing\n`
75
- * (the spec may itself contain spaces, hence the endsWith checks).
90
+ * them. Output protocol per request: `<oid> <type> <size>\n<size bytes>\n`,
91
+ * or `<spec> missing\n` (the spec may itself contain spaces, hence the
92
+ * endsWith checks).
76
93
  */
77
- async function catFileBatch(
94
+ async function catFileBatchOutcomes(
78
95
  runner: IGitRunner,
79
96
  repoDir: string,
80
97
  specs: string[],
81
- ): Promise<(string | null)[]> {
98
+ ): Promise<CatFileOutcome[]> {
82
99
  // Bytes, not text: every `<size>` in the header counts bytes, and the walk
83
100
  // below has to step by that count through content that is not all ASCII.
84
101
  // One spec per LINE is the protocol, and this walk depends on getting back
@@ -91,7 +108,7 @@ async function catFileBatch(
91
108
  input: `${specs.join('\n')}\n`,
92
109
  encoding: 'buffer',
93
110
  });
94
- const results: (string | null)[] = [];
111
+ const results: CatFileOutcome[] = [];
95
112
  let off = 0;
96
113
  for (let i = 0; i < specs.length; i++) {
97
114
  const nl = out.indexOf(0x0a, off);
@@ -99,7 +116,7 @@ async function catFileBatch(
99
116
  const header = out.subarray(off, nl).toString('utf-8');
100
117
  off = nl + 1;
101
118
  if (header.endsWith(' missing') || header.endsWith(' ambiguous')) {
102
- results.push(null);
119
+ results.push({ kind: 'missing' });
103
120
  continue;
104
121
  }
105
122
  const parts = header.split(' ');
@@ -107,14 +124,33 @@ async function catFileBatch(
107
124
  if (parts.length !== 3 || !Number.isInteger(size) || size < 0) {
108
125
  throw new Error(`unexpected git cat-file header: ${header}`);
109
126
  }
110
- // Non-blob (a directory path resolves to a tree) → null, but the
111
- // payload still has to be skipped to stay aligned.
112
- results.push(parts[1] === 'blob' ? out.subarray(off, off + size).toString('utf-8') : null);
127
+ // A non-blob (a directory path resolves to a tree) is reported as such,
128
+ // and its payload still has to be skipped to stay aligned.
129
+ results.push(
130
+ parts[1] === 'blob'
131
+ ? { kind: 'blob', text: out.subarray(off, off + size).toString('utf-8') }
132
+ : { kind: 'non-blob', type: parts[1]! },
133
+ );
113
134
  off += size + 1; // payload + trailing LF
114
135
  }
115
136
  return results;
116
137
  }
117
138
 
139
+ /**
140
+ * {@link catFileBatchOutcomes} for callers that want text or nothing: the
141
+ * blob's text, or null when the spec is missing at the ref or resolves to a
142
+ * non-blob — the own-entries reads, where a directory where a file was
143
+ * named simply carries no entries.
144
+ */
145
+ async function catFileBatch(
146
+ runner: IGitRunner,
147
+ repoDir: string,
148
+ specs: string[],
149
+ ): Promise<(string | null)[]> {
150
+ const outcomes = await catFileBatchOutcomes(runner, repoDir, specs);
151
+ return outcomes.map((o) => (o.kind === 'blob' ? o.text : null));
152
+ }
153
+
118
154
 
119
155
  interface AccessModel {
120
156
  roles: RolesIndex;
@@ -1340,7 +1376,13 @@ function ineligibleNamedEmailsResolved(
1340
1376
  * was built from (not the ref name that led there), so the per-file
1341
1377
  * own-entries reads that follow a gate resolve against exactly that tree.
1342
1378
  */
1343
- type AtRefModel = { model: AccessModel; resolvedRef: string };
1379
+ /**
1380
+ * A model built at a commit, with the commit it was built at and, when the
1381
+ * tree is not too large to hold, every path the commit carries: what the
1382
+ * own-entries reads need to tell a path that is absent at the commit (a
1383
+ * fact, remembered) from a blob the batch could not read (never remembered).
1384
+ */
1385
+ type AtRefModel = { model: AccessModel; resolvedRef: string; tree: ReadonlySet<string> | null };
1344
1386
  /**
1345
1387
  * One build's outcome: a model, no roles.yaml at the commit, or one that
1346
1388
  * doesn't parse. A git read failure is none of these: it throws
@@ -1384,22 +1426,50 @@ export class AccessControlService implements IAccessControl {
1384
1426
  *
1385
1427
  * This is what makes the change-request gates affordable. One detail read
1386
1428
  * plus one approval click runs six at-ref gates, and before this cache
1387
- * every one of them rebuilt the model from git: an `ls-tree -r` plus a
1388
- * `git show` per `access.md`, ~270ms each on a real knowledge base. Six
1389
- * builds per click was the lag behind the approve checkmark.
1429
+ * every one of them rebuilt the model from git. A build is two git calls
1430
+ * (`buildModelAtCommit`: one `ls-tree`, one `cat-file --batch` for every
1431
+ * file the model reads), and each call forks the server process, which
1432
+ * costs tens of milliseconds on a server holding a large heap.
1433
+ *
1434
+ * Bounded FIFO. The bound is sized for a listing that resolves access at
1435
+ * every open change request's two refs: one commit per open request plus
1436
+ * the targets, and a knowledge base with a hundred open requests was
1437
+ * evicting in a cycle under the old bound of 64, rebuilding every model on
1438
+ * every listing — thousands of forks, minutes of the main thread, and
1439
+ * every other request waiting behind them. A commit's tree never changes,
1440
+ * so a model is good for as long as it is held; the bound only caps memory.
1390
1441
  *
1391
- * Bounded FIFO, so a long-lived server holds the last few tips rather than
1392
- * one model per commit it ever gated against. A build that found no usable
1393
- * roles.yaml is deliberately NOT cached, and a build that saw any git read
1394
- * fail never becomes a model at all: it throws AccessUnreadableError and the
1395
- * gate fails closed for that call. Both are what a transient failure looks
1396
- * like, and pinning either to a commit would turn one hiccup into a wrong
1397
- * verdict, denying or granting, until the base branch moved. Concurrent
1398
- * misses on the same commit share one build via `atRefInFlight`.
1442
+ * A commit with no roles.yaml is remembered too (`atRefNoRoles`): the batch
1443
+ * read answers "missing" for it from git itself, which is as final as the
1444
+ * commit. What is never cached is a build that saw a git read FAIL: it
1445
+ * throws AccessUnreadableError and the gate fails closed for that call, and
1446
+ * pinning that to a commit would turn one hiccup into a wrong verdict until
1447
+ * the branch moved. Concurrent misses on the same commit share one build
1448
+ * via `atRefInFlight`.
1399
1449
  */
1400
1450
  private readonly atRefCache = new Map<string, AtRefModel>();
1451
+ private readonly atRefNoRoles = new Set<string>();
1401
1452
  private readonly atRefInFlight = new Map<string, Promise<AtRefBuild>>();
1402
- private static readonly AT_REF_CACHE_MAX = 64;
1453
+ private static readonly AT_REF_CACHE_MAX = 512;
1454
+
1455
+ /**
1456
+ * A path's own access entries AT A COMMIT, keyed by commit + path. A blob
1457
+ * at a commit never changes, so neither does what it declares for itself:
1458
+ * no TTL, no invalidation, and no workspace in the key (every clone holds
1459
+ * the same objects). This is the read every at-ref gate makes after the
1460
+ * model: one `cat-file --batch` per call for the paths asked, which a
1461
+ * change-request listing made once per ref per listing for the same paths
1462
+ * at the same commits. Bounded FIFO; the bound only caps memory.
1463
+ */
1464
+ private readonly ownEntriesAtCommit = new Map<string, OwnEntries | null>();
1465
+ private static readonly OWN_ENTRIES_AT_COMMIT_MAX = 16_384;
1466
+ /** Paths a cached model keeps of its commit's tree, at most; a larger tree is not kept. */
1467
+ private static readonly TREE_INDEX_MAX_PATHS = 20_000;
1468
+ /** Paths held across every cached model's tree, at most; past it the oldest models let their tree go. */
1469
+ private static readonly TREE_INDEX_BUDGET = 500_000;
1470
+ private readonly treeIndexMaxPaths: number;
1471
+ private readonly treeIndexBudget: number;
1472
+ private treePathsHeld = 0;
1403
1473
 
1404
1474
  /**
1405
1475
  * Canonicalised deployment-owner emails (see `AccessModel.deploymentOwners`).
@@ -1434,10 +1504,19 @@ export class AccessControlService implements IAccessControl {
1434
1504
  * run on the default layout.
1435
1505
  */
1436
1506
  private readonly kb: Pick<KbContext, 'layout'> = { layout: DEFAULT_KB_LAYOUT },
1507
+ /**
1508
+ * How much of the commits' trees the at-ref models may hold (see
1509
+ * `TREE_INDEX_MAX_PATHS`, `TREE_INDEX_BUDGET`). The defaults are the
1510
+ * deployment's; a suite lowers them to reach the bounds with a small
1511
+ * repository.
1512
+ */
1513
+ limits: { treeIndexMaxPaths?: number; treeIndexBudget?: number } = {},
1437
1514
  ) {
1438
1515
  this.deploymentOwners = new Set(
1439
1516
  deploymentOwners.filter(Boolean).map((e) => canonicalEmail(e)),
1440
1517
  );
1518
+ this.treeIndexMaxPaths = limits.treeIndexMaxPaths ?? AccessControlService.TREE_INDEX_MAX_PATHS;
1519
+ this.treeIndexBudget = limits.treeIndexBudget ?? AccessControlService.TREE_INDEX_BUDGET;
1441
1520
  }
1442
1521
 
1443
1522
  /**
@@ -1579,6 +1658,23 @@ export class AccessControlService implements IAccessControl {
1579
1658
  return result;
1580
1659
  }
1581
1660
 
1661
+ async groupsOfUser(workspaceId: string, userEmail: string): Promise<string[]> {
1662
+ const model = await this.loadModel(workspaceId);
1663
+ const email = canonicalEmail(userEmail);
1664
+ const names: string[] = [];
1665
+ for (const principal of model.roles.byCanonical.values()) {
1666
+ if (principal.kind === 'group' && principal.emails.has(email)) names.push(principal.displayName);
1667
+ }
1668
+ return names;
1669
+ }
1670
+
1671
+ async visibleGroups(workspaceId: string, userEmail: string, groups: readonly string[]): Promise<string[]> {
1672
+ if (groups.length === 0) return [];
1673
+ if (await this.holdsAdminRootWrite(workspaceId, userEmail)) return [...groups];
1674
+ const mine = new Set(await this.groupsOfUser(workspaceId, userEmail));
1675
+ return groups.filter((g) => mine.has(g));
1676
+ }
1677
+
1582
1678
  async canWriteBatch(
1583
1679
  workspaceId: string,
1584
1680
  userEmail: string,
@@ -2096,7 +2192,7 @@ export class AccessControlService implements IAccessControl {
2096
2192
  const loaded = await this.loadModelAtRef(workspaceId, ref);
2097
2193
  if (!loaded) return null;
2098
2194
  const repoDir = await this.repoDir(workspaceId);
2099
- const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
2195
+ const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
2100
2196
  return hasPermissionResolved(loaded.model, 'write', userEmail, relativePath, own);
2101
2197
  }
2102
2198
 
@@ -2109,7 +2205,7 @@ export class AccessControlService implements IAccessControl {
2109
2205
  const loaded = await this.loadModelAtRef(workspaceId, ref);
2110
2206
  if (!loaded) return null;
2111
2207
  const repoDir = await this.repoDir(workspaceId);
2112
- const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
2208
+ const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
2113
2209
  return canReadResolved(loaded.model, userEmail, relativePath, own);
2114
2210
  }
2115
2211
 
@@ -2132,7 +2228,7 @@ export class AccessControlService implements IAccessControl {
2132
2228
  const repoDir = await this.repoDir(workspaceId);
2133
2229
  // One `git cat-file --batch` for the whole path set — a per-path `git
2134
2230
  // show` spawn made CR owner-routing take minutes on large change sets.
2135
- const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
2231
+ const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
2136
2232
  const result = new Map<string, boolean>();
2137
2233
  for (const p of relativePaths) {
2138
2234
  const machineOwned = machineAnswers.get(p);
@@ -2157,7 +2253,7 @@ export class AccessControlService implements IAccessControl {
2157
2253
  if (!loaded) return null;
2158
2254
  const repoDir = await this.repoDir(workspaceId);
2159
2255
  // One `git cat-file --batch` for the whole set — see canWriteBatchAtRef.
2160
- const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
2256
+ const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
2161
2257
  const result = new Map<string, boolean>();
2162
2258
  for (const p of relativePaths) {
2163
2259
  result.set(p, canReadResolved(loaded.model, userEmail, p, owns.get(p) ?? null));
@@ -2180,7 +2276,7 @@ export class AccessControlService implements IAccessControl {
2180
2276
  const loaded = await this.loadModelAtRef(workspaceId, ref);
2181
2277
  if (!loaded) return null;
2182
2278
  const repoDir = await this.repoDir(workspaceId);
2183
- const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
2279
+ const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
2184
2280
  return eligibleHoldersResolved(loaded.model, 'write', relativePath, own);
2185
2281
  }
2186
2282
 
@@ -2294,7 +2390,7 @@ export class AccessControlService implements IAccessControl {
2294
2390
  if (!loaded) return null;
2295
2391
  const repoDir = await this.repoDir(workspaceId);
2296
2392
  // One `git cat-file --batch` for the whole path set — see canWriteBatchAtRef.
2297
- const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
2393
+ const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
2298
2394
  for (const p of relativePaths) {
2299
2395
  if (result.has(p)) continue;
2300
2396
  const own = owns.get(p) ?? null;
@@ -2512,8 +2608,14 @@ export class AccessControlService implements IAccessControl {
2512
2608
  repoDir: string,
2513
2609
  ref: string,
2514
2610
  relativePath: string,
2611
+ tree: ReadonlySet<string> | null = null,
2515
2612
  ): Promise<OwnEntries | null> {
2516
2613
  if (relativePath === 'roles.yaml') return null;
2614
+ // At a commit (what every gate passes: `resolvedRef`), the batched read
2615
+ // and its memo answer; a single `git show` stays for a symbolic ref.
2616
+ if (isCommitSha(ref)) {
2617
+ return (await this.readOwnEntriesAtRefBatch(repoDir, ref, [relativePath], tree)).get(relativePath) ?? null;
2618
+ }
2517
2619
  const text = await this.showAtRef(repoDir, ref, relativePath);
2518
2620
  if (text === null) return null;
2519
2621
  if (isAccessMdPath(relativePath)) return accessMdSelfEntries(text);
@@ -2532,23 +2634,60 @@ export class AccessControlService implements IAccessControl {
2532
2634
  repoDir: string,
2533
2635
  ref: string,
2534
2636
  relativePaths: string[],
2637
+ tree: ReadonlySet<string> | null = null,
2535
2638
  ): Promise<Map<string, OwnEntries | null>> {
2536
2639
  const result = new Map<string, OwnEntries | null>();
2537
2640
  const wanted: string[] = [];
2641
+ const memo = isCommitSha(ref) ? this.ownEntriesAtCommit : null;
2538
2642
  for (const p of relativePaths) {
2539
- if (p === 'roles.yaml') result.set(p, null);
2540
- else if (!wanted.includes(p)) wanted.push(p);
2643
+ if (p === 'roles.yaml') {
2644
+ result.set(p, null);
2645
+ continue;
2646
+ }
2647
+ const key = `${ref}\0${p}`;
2648
+ if (memo?.has(key)) {
2649
+ result.set(p, memo.get(key) ?? null);
2650
+ continue;
2651
+ }
2652
+ // A path the commit's tree does not carry has no own entries, as a
2653
+ // fact about the commit: remembered, and not read. (A change request
2654
+ // that adds a file asks about it at the base, where it does not exist
2655
+ // yet; one that deletes a file asks at the head.)
2656
+ if (tree && !tree.has(p)) {
2657
+ result.set(p, null);
2658
+ if (memo) this.rememberOwnEntriesAtCommit(key, null);
2659
+ continue;
2660
+ }
2661
+ if (!wanted.includes(p)) wanted.push(p);
2541
2662
  }
2542
2663
  if (wanted.length === 0) return result;
2543
2664
  const texts = await catFileBatch(this.gitRunner, repoDir, wanted.map((p) => `${ref}:${p}`));
2544
2665
  wanted.forEach((p, i) => {
2545
2666
  const text = texts[i];
2546
- if (text === null) result.set(p, null);
2547
- else result.set(p, isAccessMdPath(p) ? accessMdSelfEntries(text) : parseOwnAccessEntries(text));
2667
+ if (text === null) {
2668
+ // Absent at the commit (when no tree was there to say so first), or
2669
+ // a blob git could not read: the batch says "missing" for both, and
2670
+ // only the first is a fact about the commit. Answered as no own
2671
+ // entries, as before, but never remembered.
2672
+ result.set(p, null);
2673
+ return;
2674
+ }
2675
+ const own = isAccessMdPath(p) ? accessMdSelfEntries(text) : parseOwnAccessEntries(text);
2676
+ result.set(p, own);
2677
+ // What a blob declares for itself — including nothing — is a fact about the commit.
2678
+ if (memo) this.rememberOwnEntriesAtCommit(`${ref}\0${p}`, own);
2548
2679
  });
2549
2680
  return result;
2550
2681
  }
2551
2682
 
2683
+ private rememberOwnEntriesAtCommit(key: string, value: OwnEntries | null): void {
2684
+ this.ownEntriesAtCommit.set(key, value);
2685
+ if (this.ownEntriesAtCommit.size > AccessControlService.OWN_ENTRIES_AT_COMMIT_MAX) {
2686
+ const oldest = this.ownEntriesAtCommit.keys().next().value;
2687
+ if (oldest !== undefined) this.ownEntriesAtCommit.delete(oldest);
2688
+ }
2689
+ }
2690
+
2552
2691
  /**
2553
2692
  * Resolve a candidate ref to one git accepts. Falls back to `origin/<ref>`
2554
2693
  * for short branch names, matching the heuristic in
@@ -2600,28 +2739,38 @@ export class AccessControlService implements IAccessControl {
2600
2739
  /**
2601
2740
  * List every `access.md` path that exists at any depth as of `ref`.
2602
2741
  * The access tree is structure-agnostic: any `access.md` participates
2603
- * regardless of where it sits. Uses `git ls-tree -r --name-only`.
2604
- * `'error'` when git failed: a model built without its access files is
2605
- * not the model, and the caller must not cache it as one.
2742
+ * regardless of where it sits. Uses `git ls-tree -r -z --name-only`: `-z`
2743
+ * puts a NUL between paths and prints them as they are, where the default
2744
+ * output C-quotes a path with a non-ASCII or special character, which would
2745
+ * then name nothing when read back. `'error'` when git failed: a model
2746
+ * built without its access files is not the model, and the caller must not
2747
+ * cache it as one.
2606
2748
  */
2607
2749
  private async listAccessFilesAtRef(
2608
2750
  repoDir: string,
2609
2751
  ref: string,
2610
- ): Promise<{ accessFiles: string[]; pluginDirs: string[] } | 'error'> {
2752
+ ): Promise<{ accessFiles: string[]; pluginDirs: string[]; named: Set<string>; all: string[] } | 'error'> {
2611
2753
  try {
2612
- const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '--name-only', ref]);
2754
+ const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '-z', '--name-only', ref]);
2613
2755
  const out: string[] = [];
2614
2756
  const pluginDirs: string[] = [];
2757
+ const all: string[] = [];
2758
+ // The fixed-name sources the tree carries (roles.yaml, the groups
2759
+ // files): a read of one of these that comes back empty is "absent"
2760
+ // only when the tree does not name it.
2761
+ const named = new Set<string>();
2615
2762
  const { pluginsDir } = this.kb.layout;
2616
- for (const line of stdout.split('\n')) {
2617
- const p = line.trim();
2763
+ for (const p of stdout.split('\0')) {
2618
2764
  if (!p) continue;
2765
+ // One past the cap is enough to know the tree is not kept.
2766
+ if (all.length <= this.treeIndexMaxPaths) all.push(p);
2619
2767
  if (p === 'access.md' || p.endsWith('/access.md')) out.push(p);
2768
+ else if (p === 'roles.yaml' || p === SYNCED_GROUPS_YAML || p === GROUPS_YAML) named.add(p);
2620
2769
  else if (p.startsWith(`${pluginsDir}/`) && p.endsWith(`/${PLUGIN_MANIFEST_FILE}`)) {
2621
2770
  pluginDirs.push(p.slice(0, -(PLUGIN_MANIFEST_FILE.length + 1)));
2622
2771
  }
2623
2772
  }
2624
- return { accessFiles: out, pluginDirs };
2773
+ return { accessFiles: out, pluginDirs, named, all };
2625
2774
  } catch {
2626
2775
  return 'error';
2627
2776
  }
@@ -2666,6 +2815,9 @@ export class AccessControlService implements IAccessControl {
2666
2815
  const key = `${workspaceId}\0${commit}`;
2667
2816
  const hit = this.atRefCache.get(key);
2668
2817
  if (hit) return hit;
2818
+ // A commit known to carry no roles.yaml: the next candidate, without a
2819
+ // build (see `atRefNoRoles`).
2820
+ if (this.atRefNoRoles.has(key)) continue;
2669
2821
  // Registered BEFORE the first await on the build path, so concurrent
2670
2822
  // misses on one commit share a single build instead of all racing past
2671
2823
  // an empty in-flight check together.
@@ -2674,6 +2826,7 @@ export class AccessControlService implements IAccessControl {
2674
2826
  pending = this.buildModelAtCommit(repoDir, commit, candidate)
2675
2827
  .then((built) => {
2676
2828
  if (typeof built !== 'string') this.rememberAtRef(key, built);
2829
+ else if (built === 'no-roles') this.rememberNoRolesAtRef(key);
2677
2830
  return built;
2678
2831
  })
2679
2832
  .finally(() => {
@@ -2692,6 +2845,14 @@ export class AccessControlService implements IAccessControl {
2692
2845
  /** The commit `ref` points at in this clone, or null when it doesn't resolve. */
2693
2846
  private async revParseCommit(repoDir: string, ref: string): Promise<string | null> {
2694
2847
  if (!ref || ref.startsWith('-')) return null;
2848
+ // The ref files first (`readRevSha`): the names git would try, in git's
2849
+ // order, read without a process. "Missing" from the files is what
2850
+ // rev-parse answers nothing for; "unreadable" — a spelling the reader
2851
+ // does not resolve, a tag it cannot peel, a file it could not read — is
2852
+ // git's to decide, as the authority.
2853
+ const fromFiles = await readRevSha(repoDir, ref);
2854
+ if (fromFiles === 'missing') return null;
2855
+ if (fromFiles !== 'unreadable') return fromFiles.sha;
2695
2856
  try {
2696
2857
  const { stdout } = await this.gitRunner.run(repoDir, [
2697
2858
  'rev-parse',
@@ -2712,9 +2873,30 @@ export class AccessControlService implements IAccessControl {
2712
2873
 
2713
2874
  private rememberAtRef(key: string, value: AtRefModel): void {
2714
2875
  this.atRefCache.set(key, value);
2876
+ this.treePathsHeld += value.tree?.size ?? 0;
2715
2877
  if (this.atRefCache.size > AccessControlService.AT_REF_CACHE_MAX) {
2716
2878
  const oldest = this.atRefCache.keys().next().value;
2717
- if (oldest !== undefined) this.atRefCache.delete(oldest);
2879
+ if (oldest !== undefined) {
2880
+ this.treePathsHeld -= this.atRefCache.get(oldest)?.tree?.size ?? 0;
2881
+ this.atRefCache.delete(oldest);
2882
+ }
2883
+ }
2884
+ // The trees are a budget of their own, across every held model: past
2885
+ // it, the oldest models keep their model and let their tree go — the
2886
+ // gate still answers, absence is just no longer remembered for them.
2887
+ for (const held of this.atRefCache.values()) {
2888
+ if (this.treePathsHeld <= this.treeIndexBudget) break;
2889
+ if (!held.tree) continue;
2890
+ this.treePathsHeld -= held.tree.size;
2891
+ held.tree = null;
2892
+ }
2893
+ }
2894
+
2895
+ private rememberNoRolesAtRef(key: string): void {
2896
+ this.atRefNoRoles.add(key);
2897
+ if (this.atRefNoRoles.size > AccessControlService.AT_REF_CACHE_MAX) {
2898
+ const oldest = this.atRefNoRoles.values().next().value;
2899
+ if (oldest !== undefined) this.atRefNoRoles.delete(oldest);
2718
2900
  }
2719
2901
  }
2720
2902
 
@@ -2725,6 +2907,15 @@ export class AccessControlService implements IAccessControl {
2725
2907
  * means the commit carries no roles.yaml (the caller tries its next
2726
2908
  * candidate); `'malformed'` means it does but it doesn't parse (the caller
2727
2909
  * answers null, as it always has).
2910
+ *
2911
+ * TWO git calls, whatever the tree holds: one `ls-tree` to learn which
2912
+ * files the model is made of, then one `cat-file --batch` that reads all of
2913
+ * them — roles.yaml, the groups sources, every access.md and every plugin
2914
+ * manifest. It used to be one `git show` per file, and each git call forks
2915
+ * the server process, which on a server holding a large heap costs tens of
2916
+ * milliseconds of the main thread per call: a knowledge base with a few
2917
+ * dozen access files paid a second or more per commit, times every ref a
2918
+ * listing resolves.
2728
2919
  */
2729
2920
  private async buildModelAtCommit(
2730
2921
  repoDir: string,
@@ -2737,20 +2928,74 @@ export class AccessControlService implements IAccessControl {
2737
2928
  // and the caller fails closed (AccessUnreadableError, a 503) until a
2738
2929
  // retry reads the tree in full.
2739
2930
  const tag = `${label}@${commit.slice(0, 7)}`;
2931
+
2932
+ const listed = await this.listAccessFilesAtRef(repoDir, commit);
2933
+ if (listed === 'error') {
2934
+ logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
2935
+ throw new AccessUnreadableError(label, 'access.md (ls-tree)');
2936
+ }
2937
+ const wanted = [
2938
+ 'roles.yaml',
2939
+ SYNCED_GROUPS_YAML,
2940
+ GROUPS_YAML,
2941
+ ...listed.accessFiles,
2942
+ ...listed.pluginDirs.map((dir) => `${dir}/${PLUGIN_MANIFEST_FILE}`),
2943
+ ];
2944
+ let outcomes: CatFileOutcome[];
2945
+ try {
2946
+ outcomes = await catFileBatchOutcomes(this.gitRunner, repoDir, wanted.map((p) => `${commit}:${p}`));
2947
+ } catch (err) {
2948
+ // The operational signal: one line per failed build, naming the commit,
2949
+ // so a run of these is visible in the logs.
2950
+ logger(`access@${tag}`).warn(
2951
+ `git read of the access tree failed; refusing to decide from a partial tree: ${err instanceof Error ? err.message : String(err)}`,
2952
+ );
2953
+ throw new AccessUnreadableError(label, 'access tree (cat-file)');
2954
+ }
2955
+ const fetched = new Map<string, CatFileOutcome>();
2956
+ wanted.forEach((p, i) => fetched.set(p, outcomes[i] ?? { kind: 'missing' }));
2957
+ const refuse = (relativePath: string, why: string): never => {
2958
+ logger(`access@${tag}`).warn(`${relativePath} ${why}; refusing to decide from a partial tree`);
2959
+ throw new AccessUnreadableError(label, relativePath);
2960
+ };
2961
+ // What the build reads. "Missing" from the batch is "absent" only for a
2962
+ // groups source the tree does not name. A path the tree names — an
2963
+ // access.md, a plugin manifest, or a groups file the listing carried —
2964
+ // that comes back missing has a blob git could not read (a lost object),
2965
+ // and one that is not a file is a tree the model cannot be read from:
2966
+ // either way the build fails closed rather than drop what that file
2967
+ // would deny. A path the build did not prefetch (none today) is read on
2968
+ // its own, told apart from a git failure as before.
2969
+ const inTree = (relativePath: string) =>
2970
+ listed.named.has(relativePath) || !(relativePath === SYNCED_GROUPS_YAML || relativePath === GROUPS_YAML);
2740
2971
  const read = async (relativePath: string): Promise<string | null> => {
2741
- const outcome = await this.showAtRefOutcome(repoDir, commit, relativePath);
2742
- if (outcome.kind === 'error') {
2743
- // The operational signal: one line per failed read, naming the commit
2744
- // and the path, so a run of these is visible in the logs.
2745
- logger(`access@${tag}`).warn(`git read of ${relativePath} failed; refusing to decide from a partial tree`);
2746
- throw new AccessUnreadableError(label, relativePath);
2972
+ const got = fetched.get(relativePath);
2973
+ if (got === undefined) {
2974
+ const outcome = await this.showAtRefOutcome(repoDir, commit, relativePath);
2975
+ if (outcome.kind === 'error') refuse(relativePath, 'could not be read');
2976
+ return outcome.kind === 'text' ? outcome.text : null;
2747
2977
  }
2748
- return outcome.kind === 'text' ? outcome.text : null;
2978
+ if (got.kind === 'blob') return got.text;
2979
+ if (got.kind === 'non-blob') return refuse(relativePath, `is a ${got.type}, not a file`);
2980
+ if (!inTree(relativePath)) return null;
2981
+ return refuse(relativePath, 'is in the tree but its blob could not be read');
2749
2982
  };
2750
2983
 
2751
- const rolesYaml = await read('roles.yaml');
2752
- if (rolesYaml === null) return 'no-roles';
2753
- const rolesParsed = parseRolesYaml(rolesYaml);
2984
+ // roles.yaml decides the build's outcome, so its states are read
2985
+ // directly. Absent from the tree is "no roles here" (the caller tries
2986
+ // its next candidate ref). In the tree but unreadable refuses the build:
2987
+ // answering "no roles" for it would be remembered for the commit and
2988
+ // would send a short ref through to origin, which may grant. A directory
2989
+ // of that name is as malformed as a file that does not parse (the caller
2990
+ // answers null, and never falls through to a ref that might grant what
2991
+ // this one does not).
2992
+ const roles = fetched.get('roles.yaml') ?? { kind: 'missing' as const };
2993
+ if (roles.kind === 'missing') {
2994
+ if (listed.named.has('roles.yaml')) refuse('roles.yaml', 'is in the tree but its blob could not be read');
2995
+ return 'no-roles';
2996
+ }
2997
+ if (roles.kind === 'non-blob') return 'malformed';
2998
+ const rolesParsed = parseRolesYaml(roles.text);
2754
2999
  if (!rolesParsed.ok) return 'malformed';
2755
3000
 
2756
3001
  // Same group loading as the working-tree model, read AT THE COMMIT — the
@@ -2775,11 +3020,6 @@ export class AccessControlService implements IAccessControl {
2775
3020
  for (const w of mergeWarnings) logger(`access@${tag}`).warn(w);
2776
3021
 
2777
3022
  const accessFiles = new Map<string, AccessFile>();
2778
- const listed = await this.listAccessFilesAtRef(repoDir, commit);
2779
- if (listed === 'error') {
2780
- logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
2781
- throw new AccessUnreadableError(label, 'access.md (ls-tree)');
2782
- }
2783
3023
  for (const p of listed.accessFiles) {
2784
3024
  const text = await read(p);
2785
3025
  if (text === null) continue;
@@ -2824,6 +3064,10 @@ export class AccessControlService implements IAccessControl {
2824
3064
  deploymentOwners: this.deploymentOwners,
2825
3065
  },
2826
3066
  resolvedRef: commit,
3067
+ // The listing already named every path; held so an own-entries read
3068
+ // can tell absence from a lost blob, unless the tree is too large to
3069
+ // hold for every cached commit (then absence is simply not remembered).
3070
+ tree: listed.all.length <= this.treeIndexMaxPaths ? new Set(listed.all) : null,
2827
3071
  };
2828
3072
  }
2829
3073
  }
@@ -128,6 +128,18 @@ export function createAccessRoutes(
128
128
  return { roles, groups, plugins: plugins ?? [] };
129
129
  };
130
130
 
131
+ /**
132
+ * The groups `email` may be offered as a grantee, or name in a grant: the
133
+ * resolver's ONE answer to who sees which group (`visibleGroups`: every
134
+ * group for an admin, only their own for anyone else), the same the
135
+ * Library's Groups tab lists. Read on the default branch, where the active
136
+ * group source lives. The picker never names a group the caller has no
137
+ * part in, and the grant route refuses one with the same words as a group
138
+ * that does not exist.
139
+ */
140
+ const visibleGroups = (email: string, groups: string[]): Promise<string[]> =>
141
+ accessControl.visibleGroups(kb.defaultWorkspaceId(), email, groups);
142
+
131
143
  /**
132
144
  * The plugins `email` may DISCOVER — read the folder's access.md, the same
133
145
  * verdict the plugin index lists on. Both the suggest list and the grant
@@ -491,8 +503,9 @@ export function createAccessRoutes(
491
503
  * names — resolved from the DEFAULT branch, the authoritative source),
492
504
  * groups (the ACTIVE group source, same authority), and people (branch-local
493
505
  * to `:id`). Roles/groups come from the resolver's CACHED model — no fresh
494
- * file reads per keystroke. Roles + groups are small + non-sensitive so they
495
- * always show; PEOPLE are withheld until `q` is ≥ 2 chars, so an empty query
506
+ * file reads per keystroke. Roles are small + non-sensitive so they always
507
+ * show; GROUPS are the caller's own unless they are an admin (see
508
+ * `visibleGroups`); PEOPLE are withheld until `q` is ≥ 2 chars, so an empty query
496
509
  * can't dump the whole directory (email-harvesting guard). People are the
497
510
  * union of the KB-canonical set (roles.yaml + access.md grants) and the
498
511
  * `users` table (logged-in users). Results are capped. Each person carries
@@ -527,7 +540,7 @@ export function createAccessRoutes(
527
540
  .filter((g) => !q || g.toLowerCase().includes(q))
528
541
  .slice(0, CAP);
529
542
 
530
- const matchedGroups = groups
543
+ const matchedGroups = (await visibleGroups(user.email, groups))
531
544
  .filter((g) => !q || g.toLowerCase().includes(q))
532
545
  .slice(0, CAP);
533
546
 
@@ -934,9 +947,12 @@ export function createAccessRoutes(
934
947
  } else if (principal.kind === 'group') {
935
948
  // A group grant must name a group in the ACTIVE source (IdP-synced or
936
949
  // manual) on the default branch — same authority the suggest list
937
- // uses. No role-collision refusal: bare tokens resolve GROUP-FIRST,
938
- // so the written bare name IS the group.
939
- const { groups } = await defaultBranchPrincipals();
950
+ // uses — and one the caller may see (`visibleGroups`): a group hidden
951
+ // from the picker is refused here with the same words as one that
952
+ // does not exist, so the grant route is no oracle for group names.
953
+ // No role-collision refusal: bare tokens resolve GROUP-FIRST, so the
954
+ // written bare name IS the group.
955
+ const groups = await visibleGroups(user.email, (await defaultBranchPrincipals()).groups);
940
956
  const canonical = canonicalRoleName(principal.group);
941
957
  if (!groups.some((g) => canonicalRoleName(g) === canonical)) {
942
958
  throw new AccessMutationError(
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * They used to be appended, in full, to every tool description that they
5
5
  * covered: the content rule rode on all twelve file tools, the agent-guide
6
- * reminder on all of them plus the shell, the write-mode and escape-sequence
6
+ * reminder on all of them, the write-mode and escape-sequence
7
7
  * paragraphs on two or three apiece. A description then ran to two or three
8
8
  * thousand characters, most of it text the agent had already read on the tool
9
9
  * above — and clients cut long descriptions from the END, which is where the