@bevel-software/platform-core-backend 0.28.0 → 0.29.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 (143) hide show
  1. package/agent-guide/access-control.md +15 -0
  2. package/agent-guide/skills.md +8 -0
  3. package/dist/core/catalog-revision.d.ts.map +1 -1
  4. package/dist/core/catalog-revision.js +4 -2
  5. package/dist/core/catalog-revision.js.map +1 -1
  6. package/dist/core/compose-embed.d.ts +35 -0
  7. package/dist/core/compose-embed.d.ts.map +1 -0
  8. package/dist/core/compose-embed.js +14 -0
  9. package/dist/core/compose-embed.js.map +1 -0
  10. package/dist/core/core-ports.d.ts +10 -0
  11. package/dist/core/core-ports.d.ts.map +1 -1
  12. package/dist/core/core-ports.js.map +1 -1
  13. package/dist/core/create-core-server.d.ts.map +1 -1
  14. package/dist/core/create-core-server.js +7 -1
  15. package/dist/core/create-core-server.js.map +1 -1
  16. package/dist/core/create-core-services.d.ts +1 -1
  17. package/dist/core/create-core-services.d.ts.map +1 -1
  18. package/dist/core/create-core-services.js +25 -14
  19. package/dist/core/create-core-services.js.map +1 -1
  20. package/dist/core/lifecycle.d.ts +11 -6
  21. package/dist/core/lifecycle.d.ts.map +1 -1
  22. package/dist/core/lifecycle.js +13 -6
  23. package/dist/core/lifecycle.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +5 -2
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +36 -14
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +208 -85
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/auth/index.d.ts +15 -0
  31. package/dist/modules/auth/index.d.ts.map +1 -0
  32. package/dist/modules/auth/index.js +20 -0
  33. package/dist/modules/auth/index.js.map +1 -0
  34. package/dist/modules/embed/embed.interface.d.ts +22 -0
  35. package/dist/modules/embed/embed.interface.d.ts.map +1 -1
  36. package/dist/modules/embed/embed.service.d.ts +28 -14
  37. package/dist/modules/embed/embed.service.d.ts.map +1 -1
  38. package/dist/modules/embed/embed.service.js +38 -17
  39. package/dist/modules/embed/embed.service.js.map +1 -1
  40. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  41. package/dist/modules/embed/embed.tools.js +10 -2
  42. package/dist/modules/embed/embed.tools.js.map +1 -1
  43. package/dist/modules/kb-fs/index.d.ts +7 -0
  44. package/dist/modules/kb-fs/index.d.ts.map +1 -0
  45. package/dist/modules/kb-fs/index.js +2 -0
  46. package/dist/modules/kb-fs/index.js.map +1 -0
  47. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
  48. package/dist/modules/plugins/join-request-jobs.service.js +43 -8
  49. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
  50. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  51. package/dist/modules/skills/skills.tools.d.ts +8 -1
  52. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  53. package/dist/modules/skills/skills.tools.js +115 -11
  54. package/dist/modules/skills/skills.tools.js.map +1 -1
  55. package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
  56. package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
  57. package/dist/modules/tool-helpers/default-branch-write.js +63 -0
  58. package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
  59. package/dist/modules/tool-helpers/index.d.ts +1 -0
  60. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  61. package/dist/modules/tool-helpers/index.js +1 -0
  62. package/dist/modules/tool-helpers/index.js.map +1 -1
  63. package/dist/modules/workflow/file-lock.service.d.ts +27 -5
  64. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  65. package/dist/modules/workflow/file-lock.service.js +70 -18
  66. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  67. package/dist/modules/workflow/git/git.service.d.ts +13 -0
  68. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  69. package/dist/modules/workflow/git/git.service.js +24 -0
  70. package/dist/modules/workflow/git/git.service.js.map +1 -1
  71. package/dist/modules/workflow/index.d.ts +8 -0
  72. package/dist/modules/workflow/index.d.ts.map +1 -0
  73. package/dist/modules/workflow/index.js +8 -0
  74. package/dist/modules/workflow/index.js.map +1 -0
  75. package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
  76. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  77. package/dist/modules/workflow/pending-commits.service.js +4 -24
  78. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  79. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  80. package/dist/modules/workflow/workflow.routes.js +20 -1
  81. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  82. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  83. package/dist/modules/workflow/workflow.service.js +5 -3
  84. package/dist/modules/workflow/workflow.service.js.map +1 -1
  85. package/dist/modules/workspace/index.d.ts +10 -0
  86. package/dist/modules/workspace/index.d.ts.map +1 -0
  87. package/dist/modules/workspace/index.js +10 -0
  88. package/dist/modules/workspace/index.js.map +1 -0
  89. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  90. package/dist/modules/workspace/workspace.tools.js +106 -20
  91. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  92. package/dist/shared/workspace-id.d.ts +17 -0
  93. package/dist/shared/workspace-id.d.ts.map +1 -1
  94. package/dist/shared/workspace-id.js +24 -0
  95. package/dist/shared/workspace-id.js.map +1 -1
  96. package/kb-template/access.md +2 -1
  97. package/mcp-app/page.html +142 -8
  98. package/package.json +3 -3
  99. package/src/core/__tests__/compose-embed.test.ts +65 -0
  100. package/src/core/__tests__/lifecycle.test.ts +29 -26
  101. package/src/core/catalog-revision.ts +4 -2
  102. package/src/core/compose-embed.ts +50 -0
  103. package/src/core/core-ports.ts +10 -0
  104. package/src/core/create-core-server.ts +7 -1
  105. package/src/core/create-core-services.ts +27 -21
  106. package/src/core/lifecycle.ts +24 -12
  107. package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +23 -3
  108. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +303 -25
  109. package/src/modules/access/__tests__/access-control.download-read.test.ts +3 -1
  110. package/src/modules/access/__tests__/access-control.service.test.ts +77 -16
  111. package/src/modules/access/__tests__/change-read-gate.test.ts +36 -5
  112. package/src/modules/access/access-control.interface.ts +5 -2
  113. package/src/modules/access/access-control.service.ts +236 -95
  114. package/src/modules/auth/index.ts +29 -0
  115. package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
  116. package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
  117. package/src/modules/embed/embed.interface.ts +24 -0
  118. package/src/modules/embed/embed.service.ts +44 -19
  119. package/src/modules/embed/embed.tools.ts +10 -2
  120. package/src/modules/kb-fs/index.ts +6 -0
  121. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
  122. package/src/modules/plugins/join-request-jobs.service.ts +45 -8
  123. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
  124. package/src/modules/skills/skills.contract.ts +4 -0
  125. package/src/modules/skills/skills.tools.ts +120 -10
  126. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
  127. package/src/modules/tool-helpers/default-branch-write.ts +90 -0
  128. package/src/modules/tool-helpers/index.ts +5 -0
  129. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +36 -2
  130. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
  131. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
  132. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
  133. package/src/modules/workflow/file-lock.service.ts +79 -18
  134. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
  135. package/src/modules/workflow/git/git.service.ts +23 -0
  136. package/src/modules/workflow/index.ts +7 -0
  137. package/src/modules/workflow/pending-commits.service.ts +4 -23
  138. package/src/modules/workflow/workflow.routes.ts +24 -1
  139. package/src/modules/workflow/workflow.service.ts +3 -2
  140. package/src/modules/workspace/__tests__/workspace.tools.test.ts +21 -11
  141. package/src/modules/workspace/index.ts +14 -0
  142. package/src/modules/workspace/workspace.tools.ts +120 -20
  143. package/src/shared/workspace-id.ts +24 -0
@@ -15,12 +15,11 @@ import { DIRECTORY_SYNC_BOT_EMAIL, DIRECTORY_SYNC_BOT_NAME, } from './directory-
15
15
  /**
16
16
  * Read many objects from a git repo in ONE `git cat-file --batch` process.
17
17
  * `specs` are `<ref>:<path>` lines; the result array is index-aligned with
18
- * them — the blob's text, or null when the spec is missing/ambiguous at the
19
- * ref or resolves to a non-blob (e.g. a directory). Output protocol per
20
- * request: `<oid> <type> <size>\n<size bytes>\n`, or `<spec> missing\n`
21
- * (the spec may itself contain spaces, hence the endsWith checks).
18
+ * them. Output protocol per request: `<oid> <type> <size>\n<size bytes>\n`,
19
+ * or `<spec> missing\n` (the spec may itself contain spaces, hence the
20
+ * endsWith checks).
22
21
  */
23
- async function catFileBatch(runner, repoDir, specs) {
22
+ async function catFileBatchOutcomes(runner, repoDir, specs) {
24
23
  // Bytes, not text: every `<size>` in the header counts bytes, and the walk
25
24
  // below has to step by that count through content that is not all ASCII.
26
25
  // One spec per LINE is the protocol, and this walk depends on getting back
@@ -42,7 +41,7 @@ async function catFileBatch(runner, repoDir, specs) {
42
41
  const header = out.subarray(off, nl).toString('utf-8');
43
42
  off = nl + 1;
44
43
  if (header.endsWith(' missing') || header.endsWith(' ambiguous')) {
45
- results.push(null);
44
+ results.push({ kind: 'missing' });
46
45
  continue;
47
46
  }
48
47
  const parts = header.split(' ');
@@ -50,13 +49,25 @@ async function catFileBatch(runner, repoDir, specs) {
50
49
  if (parts.length !== 3 || !Number.isInteger(size) || size < 0) {
51
50
  throw new Error(`unexpected git cat-file header: ${header}`);
52
51
  }
53
- // Non-blob (a directory path resolves to a tree) → null, but the
54
- // payload still has to be skipped to stay aligned.
55
- results.push(parts[1] === 'blob' ? out.subarray(off, off + size).toString('utf-8') : null);
52
+ // A non-blob (a directory path resolves to a tree) is reported as such,
53
+ // and its payload still has to be skipped to stay aligned.
54
+ results.push(parts[1] === 'blob'
55
+ ? { kind: 'blob', text: out.subarray(off, off + size).toString('utf-8') }
56
+ : { kind: 'non-blob', type: parts[1] });
56
57
  off += size + 1; // payload + trailing LF
57
58
  }
58
59
  return results;
59
60
  }
61
+ /**
62
+ * {@link catFileBatchOutcomes} for callers that want text or nothing: the
63
+ * blob's text, or null when the spec is missing at the ref or resolves to a
64
+ * non-blob — the own-entries reads, where a directory where a file was
65
+ * named simply carries no entries.
66
+ */
67
+ async function catFileBatch(runner, repoDir, specs) {
68
+ const outcomes = await catFileBatchOutcomes(runner, repoDir, specs);
69
+ return outcomes.map((o) => (o.kind === 'blob' ? o.text : null));
70
+ }
60
71
  function isBuiltInRole(canonicalRole) {
61
72
  return canonicalRole === EVERYONE_CANONICAL;
62
73
  }
@@ -319,18 +330,36 @@ function resolveAtPath(model, verb, relativePath, fileOwn) {
319
330
  /**
320
331
  * Every principal key a caller holds: the tokens their email is a member of,
321
332
  * plus the PUBLIC keys every caller holds regardless (a plugin principal
322
- * whose plugin grants `everyone`). Undefined when there is nothing at all,
333
+ * whose plugin grants `everyone`), plus — for a deployment owner — the Admin
334
+ * role's own keys, whether or not `roles.yaml` names them: the owner IS an
335
+ * Admin (see `AccessModel.deploymentOwners`), so every rule that binds the
336
+ * role binds them, for every verb. Undefined when there is nothing at all,
323
337
  * matching the plain `byEmail.get` the tier-2 check used to read.
324
338
  */
325
339
  function principalKeysOf(model, email) {
326
340
  const own = model.roles.byEmail.get(email);
327
341
  const pub = model.roles.publicKeys;
328
- if (!pub?.size)
342
+ const asOwner = model.deploymentOwners.has(email) && !own?.has(ADMIN_ROLE_KEY);
343
+ if (!pub?.size && !asOwner)
329
344
  return own;
330
- return new Set([...(own ?? []), ...pub]);
345
+ return new Set([
346
+ ...(own ?? []),
347
+ ...(pub ?? []),
348
+ ...(asOwner ? principalKeysOfToken(model, ADMIN_ROLE_KEY) : []),
349
+ ]);
331
350
  }
332
351
  /** The explicit key of the Admin ROLE — never the bare token, which a same-named group may own. */
333
352
  const ADMIN_ROLE_KEY = `${ROLE_TOKEN_PREFIX}${ADMIN_CANONICAL}`;
353
+ /**
354
+ * The verbs the Admin floor at the repository root confers: write, and the
355
+ * read that write implies (`VERB_REQUIRES`). An admin who may change
356
+ * everything under the root may see it; otherwise the read-before-write gate
357
+ * refuses them the very change the floor grants. Download is not implied by
358
+ * write and stays with the access tree.
359
+ */
360
+ function holdsAdminFloor(verb) {
361
+ return verb === 'write' || verb === 'read';
362
+ }
334
363
  /**
335
364
  * Add to `keys` what holding them confers on top: every plugin principal
336
365
  * expanded from one of the keys, and the public keys every caller holds.
@@ -381,12 +410,13 @@ const NO_KEYS = new Set();
381
410
  /**
382
411
  * The tier-2 half of `hasPermissionResolved` on its own: a verdict for a set
383
412
  * of principal KEYS with no person behind them — no direct-email tier, no
384
- * admin rescue, no machine-owned rule — only the Admin write floor, when the
385
- * keys include the Admin role. Closest scope first; within a scope a
386
- * grant to any key wins over a denial to another, exactly as for a person.
413
+ * admin rescue, no machine-owned rule — only the Admin floor (write, and the
414
+ * read it implies), when the keys include the Admin role. Closest scope
415
+ * first; within a scope a grant to any key wins over a denial to another,
416
+ * exactly as for a person.
387
417
  */
388
418
  function hasPermissionForKeys(model, verb, keys, relativePath, fileOwn) {
389
- const adminFloor = verb === 'write' && keys.has(ADMIN_ROLE_KEY);
419
+ const adminFloor = holdsAdminFloor(verb) && keys.has(ADMIN_ROLE_KEY);
390
420
  for (const scope of resolveScopes(model, verb, relativePath, fileOwn)) {
391
421
  if (adminFloor && isAdminFloorScope(scope, relativePath))
392
422
  return true;
@@ -460,17 +490,20 @@ function isAdminEmail(model, email) {
460
490
  * owner is whoever can already set `ADMIN_EMAIL`, so this concedes no
461
491
  * authority they did not have; it only gives it a door.
462
492
  *
463
- * One floor for `write`, applied during the walk: an admin (same definition)
464
- * always holds `write` at the repository root. When the walk reaches the root
465
- * `access.md` (or the own frontmatter of a file directly in the root) — or runs
466
- * out of scopes without a verdict — an admin is granted,
467
- * whatever the root file says, so no root rule can lock the deployment out of
468
- * its own tree. A NEARER scope still decides first: a subfolder that denies
469
- * Admin (or denies `everyone` without naming Admin) excludes admins there like
470
- * anyone else. The root file's own entries are not rewritten — the floor is a
471
- * verdict, not a grant line — so `grantSources` stays file-backed.
493
+ * One floor for `write` and the `read` it implies (`holdsAdminFloor`), applied
494
+ * during the walk: an admin (same definition) always holds both at the
495
+ * repository root. When the walk reaches the root `access.md` (or the own
496
+ * frontmatter of a file directly in the root) — or runs out of scopes without
497
+ * a verdict — an admin is granted, whatever the root file says, so no root
498
+ * rule can lock the deployment out of its own tree, and nobody who may change
499
+ * everything under the root is refused the sight of it. A NEARER scope still
500
+ * decides first: a subfolder that denies Admin (or denies `everyone` without
501
+ * naming Admin) excludes admins there like anyone else. The root file's own
502
+ * entries are not rewritten — the floor is a verdict, not a grant line — so
503
+ * `grantSources` stays file-backed.
472
504
  *
473
- * No special-cases for `read`/`download` — they fall through to scope resolution.
505
+ * No special-case for `download` (or `owner`) — they fall through to scope
506
+ * resolution; write implies read, not download.
474
507
  */
475
508
  function hasPermissionResolved(model, verb, userEmail, relativePath, fileOwn) {
476
509
  return decidePermissionResolved(model, verb, userEmail, relativePath, fileOwn).allowed;
@@ -485,14 +518,12 @@ function decidePermissionResolved(model, verb, userEmail, relativePath, fileOwn)
485
518
  return { allowed: true, scope: null, via: 'admin-rescue' };
486
519
  }
487
520
  }
488
- const adminFloor = verb === 'write' && isAdminEmail(model, email);
489
- // For write, the deployment owner IS Admin — the floor admits them, so the
490
- // Admin rules along the way bind them too: a subfolder denying Admin excludes
491
- // the owner exactly as it excludes the role's members.
492
- let userRoles = principalKeysOf(model, email);
493
- if (adminFloor && !userRoles?.has(ADMIN_ROLE_KEY)) {
494
- userRoles = new Set([...(userRoles ?? []), ...principalKeysOfToken(model, ADMIN_ROLE_KEY)]);
495
- }
521
+ const adminFloor = holdsAdminFloor(verb) && isAdminEmail(model, email);
522
+ // The deployment owner IS Admin: `principalKeysOf` gives them the role's
523
+ // keys, so the Admin rules along the way bind them too — a subfolder
524
+ // denying Admin excludes the owner exactly as it excludes the role's
525
+ // members, and one granting Admin admits them.
526
+ const userRoles = principalKeysOf(model, email);
496
527
  const scopes = resolveScopes(model, verb, relativePath, fileOwn);
497
528
  for (const scope of scopes) {
498
529
  if (adminFloor && isAdminFloorScope(scope, relativePath))
@@ -858,14 +889,11 @@ function canEveryoneReadResolved(model, relativePath, fileOwn) {
858
889
  // Every *named* principal must also still read. A collapsed deny may have
859
890
  // been shadowed by a closer-scope `everyone` grant, so re-resolve each
860
891
  // candidate through the closeness-first gate rather than trusting the
861
- // collapsed deny state. (Role members live in roles.yaml; inline-named users
862
- // come from `byEmail`.)
863
- const candidates = new Set();
864
- for (const email of model.roles.byEmail.keys())
865
- candidates.add(email);
866
- for (const email of byEmail.keys())
867
- candidates.add(email);
868
- for (const email of candidates) {
892
+ // collapsed deny state. The candidates are the same set the eligible-email
893
+ // lists enumerate — roles.yaml members, inline-named users, and the
894
+ // deployment owner, whom a `deny Admin` carves out like any member of the
895
+ // role whether or not roles.yaml names them.
896
+ for (const email of namedCandidateEmails(model, byEmail)) {
869
897
  if (!hasPermissionResolved(model, 'read', email, relativePath, fileOwn))
870
898
  return false;
871
899
  }
@@ -876,11 +904,14 @@ function canEveryoneReadResolved(model, relativePath, fileOwn) {
876
904
  *
877
905
  * Read is default-deny: a path is readable only when resolution grants the
878
906
  * caller `read` directly, via one of their roles, via the built-in `everyone`
879
- * role, or via `owner:` (owners implicitly read).
907
+ * role, or via `write:`/`owner:` (both imply read).
880
908
  *
881
- * No admin rescue (mirrors `download`): admins read a node only if an
882
- * `access.md` lists them — directly, via a role, through `everyone`, or as an
883
- * owner.
909
+ * No admin rescue on root files (that is write's), but the Admin floor
910
+ * holds: an admin — a member of the role or the deployment owner — reads
911
+ * wherever no nearer scope excludes Admin, whether or not the root names it,
912
+ * because the floor gives them write there and write implies read. A
913
+ * subfolder that denies Admin, or everyone, takes the read away as it does
914
+ * for any principal.
884
915
  */
885
916
  function canReadResolved(model, userEmail, relativePath, fileOwn) {
886
917
  return hasPermissionResolved(model, 'read', userEmail, relativePath, fileOwn);
@@ -945,9 +976,10 @@ function eligibleHoldersResolved(model, verb, relativePath, fileOwn) {
945
976
  const record = model.roles.byCanonical.get(canonical);
946
977
  addPrincipal(record ? record.displayName : canonical, record?.kind ?? 'role');
947
978
  }
948
- // The Admin write floor (see `hasPermissionResolved`): Admin holds write here
949
- // whenever no nearer scope excludes it, whether or not any file names it.
950
- if (verb === 'write' && model.roles.byCanonical.has(ADMIN_ROLE_KEY)) {
979
+ // The Admin floor (see `hasPermissionResolved`): Admin holds write, and the
980
+ // read it implies, here whenever no nearer scope excludes it, whether or not
981
+ // any file names it.
982
+ if (holdsAdminFloor(verb) && model.roles.byCanonical.has(ADMIN_ROLE_KEY)) {
951
983
  const adminRole = model.roles.byCanonical.get(ADMIN_ROLE_KEY);
952
984
  if (allowedFor(principalKeysOfToken(model, ADMIN_ROLE_KEY)))
953
985
  addPrincipal(adminRole.displayName, 'role');
@@ -989,8 +1021,8 @@ function eligibleHoldersResolved(model, verb, relativePath, fileOwn) {
989
1021
  }
990
1022
  /**
991
1023
  * Every individual the model names: `roles.yaml` members, emails named inline
992
- * at this path's scope, and the deployment owner (Admin for the write floor
993
- * even when `roles.yaml` does not list them).
1024
+ * at this path's scope, and the deployment owner (bound by every rule that
1025
+ * names Admin even when `roles.yaml` does not list them).
994
1026
  */
995
1027
  function namedCandidateEmails(model, byEmail) {
996
1028
  const candidates = new Set(model.deploymentOwners);
@@ -1086,22 +1118,31 @@ export class AccessControlService {
1086
1118
  *
1087
1119
  * This is what makes the change-request gates affordable. One detail read
1088
1120
  * plus one approval click runs six at-ref gates, and before this cache
1089
- * every one of them rebuilt the model from git: an `ls-tree -r` plus a
1090
- * `git show` per `access.md`, ~270ms each on a real knowledge base. Six
1091
- * builds per click was the lag behind the approve checkmark.
1121
+ * every one of them rebuilt the model from git. A build is two git calls
1122
+ * (`buildModelAtCommit`: one `ls-tree`, one `cat-file --batch` for every
1123
+ * file the model reads), and each call forks the server process, which
1124
+ * costs tens of milliseconds on a server holding a large heap.
1125
+ *
1126
+ * Bounded FIFO. The bound is sized for a listing that resolves access at
1127
+ * every open change request's two refs: one commit per open request plus
1128
+ * the targets, and a knowledge base with a hundred open requests was
1129
+ * evicting in a cycle under the old bound of 64, rebuilding every model on
1130
+ * every listing — thousands of forks, minutes of the main thread, and
1131
+ * every other request waiting behind them. A commit's tree never changes,
1132
+ * so a model is good for as long as it is held; the bound only caps memory.
1092
1133
  *
1093
- * Bounded FIFO, so a long-lived server holds the last few tips rather than
1094
- * one model per commit it ever gated against. A build that found no usable
1095
- * roles.yaml is deliberately NOT cached, and a build that saw any git read
1096
- * fail never becomes a model at all: it throws AccessUnreadableError and the
1097
- * gate fails closed for that call. Both are what a transient failure looks
1098
- * like, and pinning either to a commit would turn one hiccup into a wrong
1099
- * verdict, denying or granting, until the base branch moved. Concurrent
1100
- * misses on the same commit share one build via `atRefInFlight`.
1134
+ * A commit with no roles.yaml is remembered too (`atRefNoRoles`): the batch
1135
+ * read answers "missing" for it from git itself, which is as final as the
1136
+ * commit. What is never cached is a build that saw a git read FAIL: it
1137
+ * throws AccessUnreadableError and the gate fails closed for that call, and
1138
+ * pinning that to a commit would turn one hiccup into a wrong verdict until
1139
+ * the branch moved. Concurrent misses on the same commit share one build
1140
+ * via `atRefInFlight`.
1101
1141
  */
1102
1142
  atRefCache = new Map();
1143
+ atRefNoRoles = new Set();
1103
1144
  atRefInFlight = new Map();
1104
- static AT_REF_CACHE_MAX = 64;
1145
+ static AT_REF_CACHE_MAX = 512;
1105
1146
  /**
1106
1147
  * Canonicalised deployment-owner emails (see `AccessModel.deploymentOwners`).
1107
1148
  * Held on the service rather than read per-model because it comes from the
@@ -2062,27 +2103,35 @@ export class AccessControlService {
2062
2103
  /**
2063
2104
  * List every `access.md` path that exists at any depth as of `ref`.
2064
2105
  * The access tree is structure-agnostic: any `access.md` participates
2065
- * regardless of where it sits. Uses `git ls-tree -r --name-only`.
2066
- * `'error'` when git failed: a model built without its access files is
2067
- * not the model, and the caller must not cache it as one.
2106
+ * regardless of where it sits. Uses `git ls-tree -r -z --name-only`: `-z`
2107
+ * puts a NUL between paths and prints them as they are, where the default
2108
+ * output C-quotes a path with a non-ASCII or special character, which would
2109
+ * then name nothing when read back. `'error'` when git failed: a model
2110
+ * built without its access files is not the model, and the caller must not
2111
+ * cache it as one.
2068
2112
  */
2069
2113
  async listAccessFilesAtRef(repoDir, ref) {
2070
2114
  try {
2071
- const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '--name-only', ref]);
2115
+ const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '-z', '--name-only', ref]);
2072
2116
  const out = [];
2073
2117
  const pluginDirs = [];
2118
+ // The fixed-name sources the tree carries (roles.yaml, the groups
2119
+ // files): a read of one of these that comes back empty is "absent"
2120
+ // only when the tree does not name it.
2121
+ const named = new Set();
2074
2122
  const { pluginsDir } = this.kb.layout;
2075
- for (const line of stdout.split('\n')) {
2076
- const p = line.trim();
2123
+ for (const p of stdout.split('\0')) {
2077
2124
  if (!p)
2078
2125
  continue;
2079
2126
  if (p === 'access.md' || p.endsWith('/access.md'))
2080
2127
  out.push(p);
2128
+ else if (p === 'roles.yaml' || p === SYNCED_GROUPS_YAML || p === GROUPS_YAML)
2129
+ named.add(p);
2081
2130
  else if (p.startsWith(`${pluginsDir}/`) && p.endsWith(`/${PLUGIN_MANIFEST_FILE}`)) {
2082
2131
  pluginDirs.push(p.slice(0, -(PLUGIN_MANIFEST_FILE.length + 1)));
2083
2132
  }
2084
2133
  }
2085
- return { accessFiles: out, pluginDirs };
2134
+ return { accessFiles: out, pluginDirs, named };
2086
2135
  }
2087
2136
  catch {
2088
2137
  return 'error';
@@ -2127,6 +2176,10 @@ export class AccessControlService {
2127
2176
  const hit = this.atRefCache.get(key);
2128
2177
  if (hit)
2129
2178
  return hit;
2179
+ // A commit known to carry no roles.yaml: the next candidate, without a
2180
+ // build (see `atRefNoRoles`).
2181
+ if (this.atRefNoRoles.has(key))
2182
+ continue;
2130
2183
  // Registered BEFORE the first await on the build path, so concurrent
2131
2184
  // misses on one commit share a single build instead of all racing past
2132
2185
  // an empty in-flight check together.
@@ -2136,6 +2189,8 @@ export class AccessControlService {
2136
2189
  .then((built) => {
2137
2190
  if (typeof built !== 'string')
2138
2191
  this.rememberAtRef(key, built);
2192
+ else if (built === 'no-roles')
2193
+ this.rememberNoRolesAtRef(key);
2139
2194
  return built;
2140
2195
  })
2141
2196
  .finally(() => {
@@ -2184,6 +2239,14 @@ export class AccessControlService {
2184
2239
  this.atRefCache.delete(oldest);
2185
2240
  }
2186
2241
  }
2242
+ rememberNoRolesAtRef(key) {
2243
+ this.atRefNoRoles.add(key);
2244
+ if (this.atRefNoRoles.size > AccessControlService.AT_REF_CACHE_MAX) {
2245
+ const oldest = this.atRefNoRoles.values().next().value;
2246
+ if (oldest !== undefined)
2247
+ this.atRefNoRoles.delete(oldest);
2248
+ }
2249
+ }
2187
2250
  /**
2188
2251
  * The uncached build behind `loadModelAtRef`: the whole access tree read
2189
2252
  * at `commit`. `label` is the candidate ref the commit came from, kept for
@@ -2191,6 +2254,15 @@ export class AccessControlService {
2191
2254
  * means the commit carries no roles.yaml (the caller tries its next
2192
2255
  * candidate); `'malformed'` means it does but it doesn't parse (the caller
2193
2256
  * answers null, as it always has).
2257
+ *
2258
+ * TWO git calls, whatever the tree holds: one `ls-tree` to learn which
2259
+ * files the model is made of, then one `cat-file --batch` that reads all of
2260
+ * them — roles.yaml, the groups sources, every access.md and every plugin
2261
+ * manifest. It used to be one `git show` per file, and each git call forks
2262
+ * the server process, which on a server holding a large heap costs tens of
2263
+ * milliseconds of the main thread per call: a knowledge base with a few
2264
+ * dozen access files paid a second or more per commit, times every ref a
2265
+ * listing resolves.
2194
2266
  */
2195
2267
  async buildModelAtCommit(repoDir, commit, label) {
2196
2268
  // Every read below tells "absent" apart from "git failed". A failure
@@ -2199,20 +2271,76 @@ export class AccessControlService {
2199
2271
  // and the caller fails closed (AccessUnreadableError, a 503) until a
2200
2272
  // retry reads the tree in full.
2201
2273
  const tag = `${label}@${commit.slice(0, 7)}`;
2274
+ const listed = await this.listAccessFilesAtRef(repoDir, commit);
2275
+ if (listed === 'error') {
2276
+ logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
2277
+ throw new AccessUnreadableError(label, 'access.md (ls-tree)');
2278
+ }
2279
+ const wanted = [
2280
+ 'roles.yaml',
2281
+ SYNCED_GROUPS_YAML,
2282
+ GROUPS_YAML,
2283
+ ...listed.accessFiles,
2284
+ ...listed.pluginDirs.map((dir) => `${dir}/${PLUGIN_MANIFEST_FILE}`),
2285
+ ];
2286
+ let outcomes;
2287
+ try {
2288
+ outcomes = await catFileBatchOutcomes(this.gitRunner, repoDir, wanted.map((p) => `${commit}:${p}`));
2289
+ }
2290
+ catch (err) {
2291
+ // The operational signal: one line per failed build, naming the commit,
2292
+ // so a run of these is visible in the logs.
2293
+ logger(`access@${tag}`).warn(`git read of the access tree failed; refusing to decide from a partial tree: ${err instanceof Error ? err.message : String(err)}`);
2294
+ throw new AccessUnreadableError(label, 'access tree (cat-file)');
2295
+ }
2296
+ const fetched = new Map();
2297
+ wanted.forEach((p, i) => fetched.set(p, outcomes[i] ?? { kind: 'missing' }));
2298
+ const refuse = (relativePath, why) => {
2299
+ logger(`access@${tag}`).warn(`${relativePath} ${why}; refusing to decide from a partial tree`);
2300
+ throw new AccessUnreadableError(label, relativePath);
2301
+ };
2302
+ // What the build reads. "Missing" from the batch is "absent" only for a
2303
+ // groups source the tree does not name. A path the tree names — an
2304
+ // access.md, a plugin manifest, or a groups file the listing carried —
2305
+ // that comes back missing has a blob git could not read (a lost object),
2306
+ // and one that is not a file is a tree the model cannot be read from:
2307
+ // either way the build fails closed rather than drop what that file
2308
+ // would deny. A path the build did not prefetch (none today) is read on
2309
+ // its own, told apart from a git failure as before.
2310
+ const inTree = (relativePath) => listed.named.has(relativePath) || !(relativePath === SYNCED_GROUPS_YAML || relativePath === GROUPS_YAML);
2202
2311
  const read = async (relativePath) => {
2203
- const outcome = await this.showAtRefOutcome(repoDir, commit, relativePath);
2204
- if (outcome.kind === 'error') {
2205
- // The operational signal: one line per failed read, naming the commit
2206
- // and the path, so a run of these is visible in the logs.
2207
- logger(`access@${tag}`).warn(`git read of ${relativePath} failed; refusing to decide from a partial tree`);
2208
- throw new AccessUnreadableError(label, relativePath);
2312
+ const got = fetched.get(relativePath);
2313
+ if (got === undefined) {
2314
+ const outcome = await this.showAtRefOutcome(repoDir, commit, relativePath);
2315
+ if (outcome.kind === 'error')
2316
+ refuse(relativePath, 'could not be read');
2317
+ return outcome.kind === 'text' ? outcome.text : null;
2209
2318
  }
2210
- return outcome.kind === 'text' ? outcome.text : null;
2319
+ if (got.kind === 'blob')
2320
+ return got.text;
2321
+ if (got.kind === 'non-blob')
2322
+ return refuse(relativePath, `is a ${got.type}, not a file`);
2323
+ if (!inTree(relativePath))
2324
+ return null;
2325
+ return refuse(relativePath, 'is in the tree but its blob could not be read');
2211
2326
  };
2212
- const rolesYaml = await read('roles.yaml');
2213
- if (rolesYaml === null)
2327
+ // roles.yaml decides the build's outcome, so its states are read
2328
+ // directly. Absent from the tree is "no roles here" (the caller tries
2329
+ // its next candidate ref). In the tree but unreadable refuses the build:
2330
+ // answering "no roles" for it would be remembered for the commit and
2331
+ // would send a short ref through to origin, which may grant. A directory
2332
+ // of that name is as malformed as a file that does not parse (the caller
2333
+ // answers null, and never falls through to a ref that might grant what
2334
+ // this one does not).
2335
+ const roles = fetched.get('roles.yaml') ?? { kind: 'missing' };
2336
+ if (roles.kind === 'missing') {
2337
+ if (listed.named.has('roles.yaml'))
2338
+ refuse('roles.yaml', 'is in the tree but its blob could not be read');
2214
2339
  return 'no-roles';
2215
- const rolesParsed = parseRolesYaml(rolesYaml);
2340
+ }
2341
+ if (roles.kind === 'non-blob')
2342
+ return 'malformed';
2343
+ const rolesParsed = parseRolesYaml(roles.text);
2216
2344
  if (!rolesParsed.ok)
2217
2345
  return 'malformed';
2218
2346
  // Same group loading as the working-tree model, read AT THE COMMIT — the
@@ -2232,11 +2360,6 @@ export class AccessControlService {
2232
2360
  for (const w of mergeWarnings)
2233
2361
  logger(`access@${tag}`).warn(w);
2234
2362
  const accessFiles = new Map();
2235
- const listed = await this.listAccessFilesAtRef(repoDir, commit);
2236
- if (listed === 'error') {
2237
- logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
2238
- throw new AccessUnreadableError(label, 'access.md (ls-tree)');
2239
- }
2240
2363
  for (const p of listed.accessFiles) {
2241
2364
  const text = await read(p);
2242
2365
  if (text === null)