@bevel-software/platform-core-backend 0.12.0 → 0.13.1

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 (177) hide show
  1. package/THIRD-PARTY-NOTICES.md +9 -7
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +8 -1
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js.map +1 -1
  7. package/dist/modules/access/access-control.service.d.ts +58 -2
  8. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  9. package/dist/modules/access/access-control.service.js +174 -33
  10. package/dist/modules/access/access-control.service.js.map +1 -1
  11. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
  12. package/dist/modules/access/admin-locked-commit.js +1 -0
  13. package/dist/modules/access/admin-locked-commit.js.map +1 -1
  14. package/dist/modules/access/synced-groups-committer.js +1 -1
  15. package/dist/modules/access/synced-groups-committer.js.map +1 -1
  16. package/dist/modules/access-model/access-errors.d.ts +11 -0
  17. package/dist/modules/access-model/access-errors.d.ts.map +1 -1
  18. package/dist/modules/access-model/access-errors.js +14 -0
  19. package/dist/modules/access-model/access-errors.js.map +1 -1
  20. package/dist/modules/access-model/access-grammar.d.ts +24 -8
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +64 -3
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
  25. package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
  26. package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
  27. package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
  28. package/dist/modules/declared-variables/index.d.ts +2 -0
  29. package/dist/modules/declared-variables/index.d.ts.map +1 -0
  30. package/dist/modules/declared-variables/index.js +2 -0
  31. package/dist/modules/declared-variables/index.js.map +1 -0
  32. package/dist/modules/diff/diff.routes.d.ts +1 -1
  33. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  34. package/dist/modules/diff/diff.routes.js +3 -3
  35. package/dist/modules/diff/diff.routes.js.map +1 -1
  36. package/dist/modules/kb-fs/clone-config.d.ts +40 -2
  37. package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
  38. package/dist/modules/kb-fs/clone-config.js +94 -2
  39. package/dist/modules/kb-fs/clone-config.js.map +1 -1
  40. package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
  41. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  42. package/dist/modules/kb-fs/locking-filesystem.js +20 -0
  43. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  44. package/dist/modules/kb-fs/repo-path.d.ts +32 -0
  45. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
  46. package/dist/modules/kb-fs/repo-path.js +54 -0
  47. package/dist/modules/kb-fs/repo-path.js.map +1 -0
  48. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  49. package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
  50. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  51. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
  52. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
  53. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
  54. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
  55. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  56. package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
  57. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  58. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  59. package/dist/modules/tool-helpers/tool-context.js +1 -0
  60. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  61. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  62. package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
  63. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  64. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  65. package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
  66. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  67. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
  68. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  69. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
  70. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  71. package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
  72. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  73. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  74. package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
  75. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  76. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
  77. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
  78. package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
  79. package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
  80. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
  81. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  82. package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
  83. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  84. package/dist/modules/workflow/git/git.service.d.ts +32 -1
  85. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  86. package/dist/modules/workflow/git/git.service.js +70 -5
  87. package/dist/modules/workflow/git/git.service.js.map +1 -1
  88. package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
  89. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  90. package/dist/modules/workflow/git/pull-request.service.js +20 -2
  91. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  92. package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
  93. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  94. package/dist/modules/workflow/pending-commits.worker.js +74 -16
  95. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  96. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  97. package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
  98. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  99. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  100. package/dist/modules/workflow/workflow.routes.js +12 -0
  101. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  102. package/dist/modules/workflow/workflow.service.d.ts +39 -0
  103. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  104. package/dist/modules/workflow/workflow.service.js +116 -6
  105. package/dist/modules/workflow/workflow.service.js.map +1 -1
  106. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
  107. package/dist/modules/workspace/startup/kb-git.js +21 -4
  108. package/dist/modules/workspace/startup/kb-git.js.map +1 -1
  109. package/dist/modules/workspace/workspace.service.d.ts +52 -8
  110. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  111. package/dist/modules/workspace/workspace.service.js +121 -23
  112. package/dist/modules/workspace/workspace.service.js.map +1 -1
  113. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  114. package/dist/modules/workspace/workspace.tools.js +31 -15
  115. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  116. package/kb-template/AGENTS.md +52 -8
  117. package/package.json +6 -5
  118. package/src/core/create-core-server.ts +13 -1
  119. package/src/core/create-core-services.ts +1 -0
  120. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
  121. package/src/modules/access/__tests__/access-groups.test.ts +28 -0
  122. package/src/modules/access/access-control.service.ts +198 -37
  123. package/src/modules/access/admin-locked-commit.ts +1 -0
  124. package/src/modules/access/synced-groups-committer.ts +1 -1
  125. package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
  126. package/src/modules/access-model/access-errors.ts +19 -0
  127. package/src/modules/access-model/access-grammar.ts +67 -3
  128. package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
  129. package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
  130. package/src/modules/declared-variables/index.ts +1 -0
  131. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
  132. package/src/modules/diff/diff.routes.ts +3 -2
  133. package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
  134. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
  135. package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
  136. package/src/modules/kb-fs/clone-config.ts +97 -2
  137. package/src/modules/kb-fs/locking-filesystem.ts +30 -0
  138. package/src/modules/kb-fs/repo-path.ts +56 -0
  139. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
  140. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
  141. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
  142. package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
  143. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
  144. package/src/modules/secrets-vault/secrets-vault.routes.ts +20 -2
  145. package/src/modules/tool-helpers/tool-context.ts +1 -0
  146. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
  147. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
  148. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
  149. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
  150. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
  151. package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
  152. package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
  153. package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
  154. package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
  155. package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
  156. package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
  157. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
  158. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
  159. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
  160. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
  161. package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
  162. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
  163. package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
  164. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
  165. package/src/modules/workflow/git/git.service.ts +73 -6
  166. package/src/modules/workflow/git/pull-request.service.ts +22 -4
  167. package/src/modules/workflow/pending-commits.worker.ts +80 -18
  168. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
  169. package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
  170. package/src/modules/workflow/workflow.routes.ts +12 -0
  171. package/src/modules/workflow/workflow.service.ts +123 -7
  172. package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
  173. package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
  174. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
  175. package/src/modules/workspace/startup/kb-git.ts +20 -7
  176. package/src/modules/workspace/workspace.service.ts +132 -25
  177. package/src/modules/workspace/workspace.tools.ts +35 -15
@@ -13,7 +13,7 @@ import type {
13
13
  GrantSources,
14
14
  ResolvedPrincipal,
15
15
  } from './access-control.interface.js';
16
- import { AccessConfigError } from '../access-model/access-errors.js';
16
+ import { AccessConfigError, AccessUnreadableError } from '../access-model/access-errors.js';
17
17
  import {
18
18
  GROUPS_YAML,
19
19
  SYNCED_GROUPS_YAML,
@@ -193,6 +193,14 @@ export async function loadActiveGroups(
193
193
  try {
194
194
  syncedText = await read(SYNCED_GROUPS_YAML);
195
195
  } catch (err) {
196
+ // An at-ref `read` throws this when git itself failed — the file's
197
+ // existence is UNKNOWN, not absent and not broken. That must propagate:
198
+ // swallowing it into a broken-groups marker builds a model with no groups
199
+ // in it and caches that model for the commit, so every group-based grant
200
+ // and denial at that commit is wrong on the strength of a flaky
201
+ // subprocess. roles.yaml and access.md already fail the build closed on
202
+ // the same error; groups cannot be the one input that does not.
203
+ if (err instanceof AccessUnreadableError) throw err;
196
204
  if (isAbsenceError(err)) {
197
205
  syncedText = null;
198
206
  } else {
@@ -211,6 +219,7 @@ export async function loadActiveGroups(
211
219
  try {
212
220
  text = await read(GROUPS_YAML);
213
221
  } catch (err) {
222
+ if (err instanceof AccessUnreadableError) throw err; // see above
214
223
  if (isAbsenceError(err)) text = null;
215
224
  else return broken(GROUPS_YAML, err instanceof Error ? err.message : String(err));
216
225
  }
@@ -764,6 +773,19 @@ function ineligibleNamedEmailsResolved(
764
773
  // Service
765
774
  // ---------------------------------------------------------------------------
766
775
 
776
+ /**
777
+ * An access model read at a git ref. `resolvedRef` is the COMMIT the model
778
+ * was built from (not the ref name that led there), so the per-file
779
+ * own-entries reads that follow a gate resolve against exactly that tree.
780
+ */
781
+ type AtRefModel = { model: AccessModel; resolvedRef: string };
782
+ /**
783
+ * One build's outcome: a model, no roles.yaml at the commit, or one that
784
+ * doesn't parse. A git read failure is none of these: it throws
785
+ * AccessUnreadableError out of the build instead.
786
+ */
787
+ type AtRefBuild = AtRefModel | 'no-roles' | 'malformed';
788
+
767
789
  export class AccessControlService implements IAccessControl {
768
790
  /**
769
791
  * Per-workspace cache: `model` is the resolved access tree and `loadedAt`
@@ -792,6 +814,31 @@ export class AccessControlService implements IAccessControl {
792
814
  >();
793
815
  private static readonly OWN_ENTRIES_TTL_MS = 5 * 60_000;
794
816
 
817
+ /**
818
+ * At-ref models keyed by workspace + the COMMIT the ref resolved to. The
819
+ * tree at a commit is immutable, so an entry can never go stale: a push
820
+ * that moves `origin/<base>` resolves to a new commit and simply misses.
821
+ * No TTL, and `invalidate()` leaves it alone, for the same reason.
822
+ *
823
+ * This is what makes the change-request gates affordable. One detail read
824
+ * plus one approval click runs six at-ref gates, and before this cache
825
+ * every one of them rebuilt the model from git: an `ls-tree -r` plus a
826
+ * `git show` per `access.md`, ~270ms each on a real knowledge base. Six
827
+ * builds per click was the lag behind the approve checkmark.
828
+ *
829
+ * Bounded FIFO, so a long-lived server holds the last few tips rather than
830
+ * one model per commit it ever gated against. A build that found no usable
831
+ * roles.yaml is deliberately NOT cached, and a build that saw any git read
832
+ * fail never becomes a model at all: it throws AccessUnreadableError and the
833
+ * gate fails closed for that call. Both are what a transient failure looks
834
+ * like, and pinning either to a commit would turn one hiccup into a wrong
835
+ * verdict, denying or granting, until the base branch moved. Concurrent
836
+ * misses on the same commit share one build via `atRefInFlight`.
837
+ */
838
+ private readonly atRefCache = new Map<string, AtRefModel>();
839
+ private readonly atRefInFlight = new Map<string, Promise<AtRefBuild>>();
840
+ private static readonly AT_REF_CACHE_MAX = 64;
841
+
795
842
  /**
796
843
  * Canonicalised deployment-owner emails (see `AccessModel.deploymentOwners`).
797
844
  * Held on the service rather than read per-model because it comes from the
@@ -817,7 +864,9 @@ export class AccessControlService implements IAccessControl {
817
864
 
818
865
  /**
819
866
  * Drop a workspace's cached model + frontmatter memo. Call after operations
820
- * that mutate the working tree — commit, push, pull, branch switch.
867
+ * that mutate the working tree — commit, push, pull, branch switch. The
868
+ * at-ref cache is untouched on purpose: its entries are keyed by commit,
869
+ * and nothing that happens in a working tree changes what a commit holds.
821
870
  */
822
871
  invalidate(workspaceId: string): void {
823
872
  this.cache.delete(workspaceId);
@@ -1515,18 +1564,46 @@ export class AccessControlService implements IAccessControl {
1515
1564
  /**
1516
1565
  * Read a file's content at a specific ref via `git show <ref>:<path>`.
1517
1566
  * Returns null when the file is absent on that ref (or the ref doesn't
1518
- * resolve).
1567
+ * resolve) and also on any other git failure: the single-path gates that
1568
+ * call this have always treated the two alike. The at-ref model build
1569
+ * needs them told apart, so it reads through `showAtRefOutcome` instead.
1519
1570
  */
1520
1571
  private async showAtRef(repoDir: string, ref: string, relativePath: string): Promise<string | null> {
1572
+ const outcome = await this.showAtRefOutcome(repoDir, ref, relativePath);
1573
+ return outcome.kind === 'text' ? outcome.text : null;
1574
+ }
1575
+
1576
+ /**
1577
+ * `git show <ref>:<path>`, telling "not there" apart from "git failed".
1578
+ * Absence is what git reports as `path '<p>' does not exist in '<ref>'`
1579
+ * (or `exists on disk, but not in`), plus an unresolvable ref; anything
1580
+ * else (an IO error, a corrupt object, a dying subprocess) is `error`.
1581
+ * `LC_ALL=C` pins the messages this classifies to English.
1582
+ */
1583
+ private async showAtRefOutcome(
1584
+ repoDir: string,
1585
+ ref: string,
1586
+ relativePath: string,
1587
+ ): Promise<{ kind: 'text'; text: string } | { kind: 'absent' } | { kind: 'error' }> {
1521
1588
  try {
1522
1589
  const { stdout } = await execFileAsync(
1523
1590
  'git',
1524
1591
  ['-C', repoDir, 'show', `${ref}:${relativePath}`],
1525
- { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 },
1592
+ {
1593
+ encoding: 'utf-8',
1594
+ maxBuffer: 16 * 1024 * 1024,
1595
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
1596
+ },
1526
1597
  );
1527
- return stdout;
1528
- } catch {
1529
- return null;
1598
+ return { kind: 'text', text: stdout };
1599
+ } catch (err) {
1600
+ const stderr =
1601
+ (err as { stderr?: string }).stderr ?? (err instanceof Error ? err.message : String(err));
1602
+ return /does not exist in|exists on disk, but not in|invalid object name|unknown revision|bad revision/.test(
1603
+ stderr,
1604
+ )
1605
+ ? { kind: 'absent' }
1606
+ : { kind: 'error' };
1530
1607
  }
1531
1608
  }
1532
1609
 
@@ -1534,8 +1611,10 @@ export class AccessControlService implements IAccessControl {
1534
1611
  * List every `access.md` path that exists at any depth as of `ref`.
1535
1612
  * The access tree is structure-agnostic: any `access.md` participates
1536
1613
  * regardless of where it sits. Uses `git ls-tree -r --name-only`.
1614
+ * `'error'` when git failed: a model built without its access files is
1615
+ * not the model, and the caller must not cache it as one.
1537
1616
  */
1538
- private async listAccessFilesAtRef(repoDir: string, ref: string): Promise<string[]> {
1617
+ private async listAccessFilesAtRef(repoDir: string, ref: string): Promise<string[] | 'error'> {
1539
1618
  try {
1540
1619
  const { stdout } = await execFileAsync(
1541
1620
  'git',
@@ -1550,7 +1629,7 @@ export class AccessControlService implements IAccessControl {
1550
1629
  }
1551
1630
  return out;
1552
1631
  } catch {
1553
- return [];
1632
+ return 'error';
1554
1633
  }
1555
1634
  }
1556
1635
 
@@ -1569,11 +1648,14 @@ export class AccessControlService implements IAccessControl {
1569
1648
  * the gate becomes useful. NEVER fall back to the working tree here: a
1570
1649
  * working-tree fallback lets anyone with edit access to access.md grant
1571
1650
  * themselves approval rights.
1651
+ *
1652
+ * Cached per commit (see `atRefCache`): the ref is resolved to the commit
1653
+ * it points at first, and that commit is both the cache key and the ref
1654
+ * every read is pinned to. `resolvedRef` in the result is therefore a
1655
+ * commit SHA, so the own-entries read callers do afterwards can never
1656
+ * straddle a push that lands between the two.
1572
1657
  */
1573
- private async loadModelAtRef(
1574
- workspaceId: string,
1575
- ref: string,
1576
- ): Promise<{ model: AccessModel; resolvedRef: string } | null> {
1658
+ private async loadModelAtRef(workspaceId: string, ref: string): Promise<AtRefModel | null> {
1577
1659
  const repoDir = await this.repoDir(workspaceId);
1578
1660
 
1579
1661
  // Refresh remote-tracking refs so a PR branch we've never personally
@@ -1581,47 +1663,126 @@ export class AccessControlService implements IAccessControl {
1581
1663
  // block the lookup if the ref already exists locally.
1582
1664
  await this.workspaceService.ensureRemotesFetched(workspaceId).catch(() => undefined);
1583
1665
 
1584
- let rolesYaml: string | null = null;
1585
- let resolvedRef: string | null = null;
1666
+ // Same candidate order as before the cache existed: the first candidate
1667
+ // that carries a roles.yaml wins, so a local branch without one still
1668
+ // defers to `origin/<branch>`.
1586
1669
  for (const candidate of this.refCandidates(ref)) {
1587
- const text = await this.showAtRef(repoDir, candidate, 'roles.yaml');
1588
- if (text !== null) {
1589
- rolesYaml = text;
1590
- resolvedRef = candidate;
1591
- break;
1670
+ const commit = await this.revParseCommit(repoDir, candidate);
1671
+ if (!commit) continue;
1672
+ const key = `${workspaceId}\0${commit}`;
1673
+ const hit = this.atRefCache.get(key);
1674
+ if (hit) return hit;
1675
+ // Registered BEFORE the first await on the build path, so concurrent
1676
+ // misses on one commit share a single build instead of all racing past
1677
+ // an empty in-flight check together.
1678
+ let pending = this.atRefInFlight.get(key);
1679
+ if (!pending) {
1680
+ pending = this.buildModelAtCommit(repoDir, commit, candidate)
1681
+ .then((built) => {
1682
+ if (typeof built !== 'string') this.rememberAtRef(key, built);
1683
+ return built;
1684
+ })
1685
+ .finally(() => {
1686
+ if (this.atRefInFlight.get(key) === pending) this.atRefInFlight.delete(key);
1687
+ });
1688
+ this.atRefInFlight.set(key, pending);
1592
1689
  }
1690
+ const built = await pending;
1691
+ if (built === 'no-roles') continue;
1692
+ if (built === 'malformed') return null;
1693
+ return built;
1694
+ }
1695
+ return null;
1696
+ }
1697
+
1698
+ /** The commit `ref` points at in this clone, or null when it doesn't resolve. */
1699
+ private async revParseCommit(repoDir: string, ref: string): Promise<string | null> {
1700
+ if (!ref || ref.startsWith('-')) return null;
1701
+ try {
1702
+ const { stdout } = await execFileAsync(
1703
+ 'git',
1704
+ ['-C', repoDir, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`],
1705
+ { encoding: 'utf-8' },
1706
+ );
1707
+ const sha = stdout.trim();
1708
+ return /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
1709
+ } catch {
1710
+ return null;
1593
1711
  }
1594
- if (!rolesYaml || !resolvedRef) return null;
1712
+ }
1595
1713
 
1714
+ private rememberAtRef(key: string, value: AtRefModel): void {
1715
+ this.atRefCache.set(key, value);
1716
+ if (this.atRefCache.size > AccessControlService.AT_REF_CACHE_MAX) {
1717
+ const oldest = this.atRefCache.keys().next().value;
1718
+ if (oldest !== undefined) this.atRefCache.delete(oldest);
1719
+ }
1720
+ }
1721
+
1722
+ /**
1723
+ * The uncached build behind `loadModelAtRef`: the whole access tree read
1724
+ * at `commit`. `label` is the candidate ref the commit came from, kept for
1725
+ * log lines only; every git read here is pinned to the commit. `'no-roles'`
1726
+ * means the commit carries no roles.yaml (the caller tries its next
1727
+ * candidate); `'malformed'` means it does but it doesn't parse (the caller
1728
+ * answers null, as it always has).
1729
+ */
1730
+ private async buildModelAtCommit(
1731
+ repoDir: string,
1732
+ commit: string,
1733
+ label: string,
1734
+ ): Promise<AtRefBuild> {
1735
+ // Every read below tells "absent" apart from "git failed". A failure
1736
+ // refuses the whole build: a model missing one access.md could grant what
1737
+ // that file denies, so no verdict is answered from it, nothing is cached,
1738
+ // and the caller fails closed (AccessUnreadableError, a 503) until a
1739
+ // retry reads the tree in full.
1740
+ const tag = `${label}@${commit.slice(0, 7)}`;
1741
+ const read = async (relativePath: string): Promise<string | null> => {
1742
+ const outcome = await this.showAtRefOutcome(repoDir, commit, relativePath);
1743
+ if (outcome.kind === 'error') {
1744
+ // The operational signal: one line per failed read, naming the commit
1745
+ // and the path, so a run of these is visible in the logs.
1746
+ console.warn(`[access@${tag}] git read of ${relativePath} failed; refusing to decide from a partial tree`);
1747
+ throw new AccessUnreadableError(label, relativePath);
1748
+ }
1749
+ return outcome.kind === 'text' ? outcome.text : null;
1750
+ };
1751
+
1752
+ const rolesYaml = await read('roles.yaml');
1753
+ if (rolesYaml === null) return 'no-roles';
1596
1754
  const rolesParsed = parseRolesYaml(rolesYaml);
1597
- if (!rolesParsed.ok) return null;
1755
+ if (!rolesParsed.ok) return 'malformed';
1598
1756
 
1599
- // Same group loading as the working-tree model, read AT THE REF — the
1757
+ // Same group loading as the working-tree model, read AT THE COMMIT — the
1600
1758
  // whole point of file-materialized groups is that the merge/push gates
1601
- // can evaluate them at the commit they gate. (`showAtRef` folds every git
1602
- // failure into null/absent — at-ref reads cannot distinguish a missing
1603
- // path from a repo error, so the broken-groups marker here fires only on
1604
- // parse failures.)
1605
- const activeGroups = await loadActiveGroups((filename) =>
1606
- this.showAtRef(repoDir, resolvedRef, filename),
1607
- );
1759
+ // can evaluate them at the commit they gate. `read` throws
1760
+ // `AccessUnreadableError` on a git failure and `loadActiveGroups` lets
1761
+ // that through, so a flaky subprocess fails this build closed exactly as
1762
+ // it does for roles.yaml and access.md; the broken-groups marker below
1763
+ // fires only on a file that was read and would not parse.
1764
+ const activeGroups = await loadActiveGroups(read);
1608
1765
  if (!activeGroups.health.ok) {
1609
1766
  console.error(
1610
- `[access@${resolvedRef}] groups source ${activeGroups.health.file} is broken (${activeGroups.health.reason}) — groups contribute nothing until it is fixed`,
1767
+ `[access@${tag}] groups source ${activeGroups.health.file} is broken (${activeGroups.health.reason}) — groups contribute nothing until it is fixed`,
1611
1768
  );
1612
1769
  }
1613
- for (const w of activeGroups.warnings) console.warn(`[access@${resolvedRef}] ${w}`);
1770
+ for (const w of activeGroups.warnings) console.warn(`[access@${tag}] ${w}`);
1614
1771
  const mergeWarnings = mergeGroupsIntoRoles(
1615
1772
  rolesParsed.index,
1616
1773
  activeGroups.groups,
1617
1774
  activeGroups.sourceFile,
1618
1775
  );
1619
- for (const w of mergeWarnings) console.warn(`[access@${resolvedRef}] ${w}`);
1776
+ for (const w of mergeWarnings) console.warn(`[access@${tag}] ${w}`);
1620
1777
 
1621
1778
  const accessFiles = new Map<string, AccessFile>();
1622
- const accessPaths = await this.listAccessFilesAtRef(repoDir, resolvedRef);
1623
- for (const p of accessPaths) {
1624
- const text = await this.showAtRef(repoDir, resolvedRef, p);
1779
+ const listed = await this.listAccessFilesAtRef(repoDir, commit);
1780
+ if (listed === 'error') {
1781
+ console.warn(`[access@${tag}] listing access.md files failed; refusing to decide from a partial tree`);
1782
+ throw new AccessUnreadableError(label, 'access.md (ls-tree)');
1783
+ }
1784
+ for (const p of listed) {
1785
+ const text = await read(p);
1625
1786
  if (text === null) continue;
1626
1787
  const parsed = parseAccessFile(text, p);
1627
1788
  if (!parsed.ok) continue;
@@ -1652,7 +1813,7 @@ export class AccessControlService implements IAccessControl {
1652
1813
  // land — the lockout moved one step later, not removed.
1653
1814
  deploymentOwners: this.deploymentOwners,
1654
1815
  },
1655
- resolvedRef,
1816
+ resolvedRef: commit,
1656
1817
  };
1657
1818
  }
1658
1819
  }
@@ -136,6 +136,7 @@ export class AdminLockedCommits {
136
136
  workspaceId,
137
137
  branch: this.defaultBranch,
138
138
  user: actor,
139
+ kbDirName: this.deps.kbDirName,
139
140
  validateWrite: this.deps.validateWrite,
140
141
  },
141
142
  );
@@ -74,7 +74,7 @@ export function createSyncedGroupsCommitter(deps: {
74
74
  // roles admin's atomic writes use.
75
75
  const fsys = new LockingFilesystem(
76
76
  { basePath, contained: true },
77
- { workflow: workflowService, workspaceId, branch, user: bot },
77
+ { workflow: workflowService, workspaceId, branch, user: bot, kbDirName },
78
78
  );
79
79
  try {
80
80
  await fsys.writeFiles(
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseYamlSubset } from '../access-grammar.js';
2
+ import {
3
+ parseYamlSubset,
4
+ hasAccessFrontmatterExtension,
5
+ registerAccessFrontmatterExtensions,
6
+ accessFrontmatterExtensionList,
7
+ parseOwnAccessEntries,
8
+ } from '../access-grammar.js';
3
9
  import { parseGroupsFile } from '../group-files.js';
4
10
 
5
11
  describe('parseYamlSubset — inline empty collections', () => {
@@ -29,3 +35,97 @@ describe('parseGroupsFile — empty group sources', () => {
29
35
  if (res.ok) expect(res.groups.size).toBe(0);
30
36
  });
31
37
  });
38
+
39
+ describe('the access-frontmatter extension set', () => {
40
+ // Registration is process-global and deliberately has no way to undo it, so
41
+ // these tests never assert that a REAL extension is absent — that would
42
+ // depend on no earlier test (in any order, in any file) having registered it.
43
+ // They use extensions nothing else will ever register instead, which makes
44
+ // every assertion here true regardless of what ran first.
45
+ it('covers nodes and tool manuals out of the box', () => {
46
+ // `.tool` is whole-document YAML with its verbs as ordinary keys inside;
47
+ // `access.md` is covered by `.md`.
48
+ expect(hasAccessFrontmatterExtension('Data/E/Knowledge/T.md')).toBe(true);
49
+ expect(hasAccessFrontmatterExtension('Plugins/E/tools/github.tool')).toBe(true);
50
+ expect(hasAccessFrontmatterExtension('Plugins/E/access.md')).toBe(true);
51
+ });
52
+
53
+ it('is case-sensitive on the path, as it always was', () => {
54
+ // Which files are governed must not change because the set became
55
+ // registrable. An uppercase extension was never a node and still is not.
56
+ expect(hasAccessFrontmatterExtension('Data/E/Knowledge/T.MD')).toBe(false);
57
+ expect(hasAccessFrontmatterExtension('Plugins/E/tools/github.TOOL')).toBe(false);
58
+ // Registration itself normalizes, so a mixed-case REGISTRATION governs the
59
+ // lowercase files it meant.
60
+ registerAccessFrontmatterExtensions(['.CaseKind']);
61
+ expect(hasAccessFrontmatterExtension('Overlay/x.casekind')).toBe(true);
62
+ expect(hasAccessFrontmatterExtension('Overlay/x.CaseKind')).toBe(false);
63
+ });
64
+
65
+ it('covers nothing an overlay has not registered', () => {
66
+ // The grammar is core's; the file kinds are not necessarily.
67
+ expect(hasAccessFrontmatterExtension('Some/File.neverregistered')).toBe(false);
68
+ expect(hasAccessFrontmatterExtension('notes.txt')).toBe(false);
69
+ });
70
+
71
+ it('covers a file kind once an overlay registers it', () => {
72
+ expect(hasAccessFrontmatterExtension('Overlay/thing.testkind')).toBe(false);
73
+ registerAccessFrontmatterExtensions(['.testkind']);
74
+ expect(hasAccessFrontmatterExtension('Overlay/thing.testkind')).toBe(true);
75
+ // A near-miss must still miss: a backup is not a live grant.
76
+ expect(hasAccessFrontmatterExtension('Overlay/thing.testkind.bak')).toBe(false);
77
+ });
78
+
79
+ it('is additive and idempotent', () => {
80
+ // Removing an extension would silently drop grants already being enforced,
81
+ // so there is no way to remove one.
82
+ registerAccessFrontmatterExtensions(['.testidem']);
83
+ const after = accessFrontmatterExtensionList().length;
84
+ registerAccessFrontmatterExtensions(['.testidem', '.TESTIDEM']);
85
+ expect(accessFrontmatterExtensionList()).toHaveLength(after);
86
+ expect(accessFrontmatterExtensionList()).toContain('.md');
87
+ expect(accessFrontmatterExtensionList()).toContain('.tool');
88
+ });
89
+
90
+ it('throws on a malformed extension rather than skipping it', () => {
91
+ // A typo would otherwise leave capability-granting files ungoverned, at
92
+ // boot, with nothing to distinguish it from a successful registration.
93
+ expect(() => registerAccessFrontmatterExtensions(['pipeline'])).toThrow(/malformed/);
94
+ expect(() => registerAccessFrontmatterExtensions(['.two.dots'])).toThrow(/malformed/);
95
+ expect(() => registerAccessFrontmatterExtensions([''])).toThrow(/malformed/);
96
+ expect(hasAccessFrontmatterExtension('x.pipeline_typo_guard')).toBe(false);
97
+ });
98
+
99
+ it('applies nothing from a list that contains a malformed entry', () => {
100
+ // Validate all, then apply: a list half-applied when it throws leaves later
101
+ // scans governing a set nobody asked for.
102
+ expect(() => registerAccessFrontmatterExtensions(['.validfirst', 'broken'])).toThrow(/malformed/);
103
+ expect(hasAccessFrontmatterExtension('a.validfirst')).toBe(false);
104
+ });
105
+
106
+ it('reads verbs out of a registered file kind, ignoring its other keys', () => {
107
+ // The point of per-file access on these: they are configuration rather than
108
+ // graph nodes, but they are exactly the files whose edits grant capability.
109
+ // The verbs are ordinary keys beside the rest of the definition, exactly as
110
+ // a `.tool` carries them beside `id:` and `tools:`.
111
+ const pipeline = [
112
+ '---',
113
+ 'name: Coding Delivery',
114
+ 'owner: Razvan <razvan@bevel.software>',
115
+ 'read:',
116
+ ' - coding-agent <coding-agent@bevel.software>',
117
+ 'do:',
118
+ ' - name: Coding',
119
+ '---',
120
+ ].join('\n');
121
+ const entries = parseOwnAccessEntries(pipeline);
122
+ expect(entries).not.toBeNull();
123
+ expect(entries!.owner).toEqual([
124
+ { kind: 'user', email: 'razvan@bevel.software', displayName: 'Razvan', deny: false },
125
+ ]);
126
+ expect(entries!.read).toEqual([
127
+ { kind: 'user', email: 'coding-agent@bevel.software', displayName: 'coding-agent', deny: false },
128
+ ]);
129
+ expect(entries!.write).toEqual([]);
130
+ });
131
+ });
@@ -40,6 +40,25 @@ export class AccessDeniedError extends WorkflowDomainError {
40
40
  }
41
41
  }
42
42
 
43
+ /**
44
+ * Thrown when the access tree at a git ref could not be READ (a git
45
+ * subprocess failed on the way), as opposed to being absent or malformed.
46
+ * Nothing was decided: a verdict from a partially read tree could grant what
47
+ * a lost `access.md` would have denied, so the operation fails closed with a
48
+ * 503 and the caller retries. Distinct from AccessConfigError (the config is
49
+ * there and wrong) and AccessDeniedError (a real permission decision).
50
+ */
51
+ export class AccessUnreadableError extends WorkflowDomainError {
52
+ constructor(ref: string, relativePath: string) {
53
+ super(
54
+ `Access rules at ${ref} could not be read (git failed on ${relativePath}); nothing was decided. Try again.`,
55
+ 503,
56
+ { ref, path: relativePath },
57
+ );
58
+ this.name = 'AccessUnreadableError';
59
+ }
60
+ }
61
+
43
62
  /**
44
63
  * Thrown when the access-control config (roles.yaml or access.md) is missing
45
64
  * or malformed at runtime — distinct from AccessDeniedError because the cause
@@ -138,12 +138,76 @@ export function isAccessMdPath(p: string): boolean {
138
138
  * the access-declarations scan and the shared `KbReferenceScanner` (which
139
139
  * must scan/rewrite the same set, or a rename strands a live `.tool`
140
140
  * frontmatter grant). `access.md` is covered by `.md`.
141
+ *
142
+ * `.tool` is whole-document YAML: the file IS one `---` fenced block, and its
143
+ * access verbs sit in it as ordinary keys beside the rest of the definition
144
+ * (`parseOwnAccessEntries` ignores every key that is not a verb). It is
145
+ * configuration rather than a graph node — it lives outside the typed
146
+ * ontologies — but it is exactly the kind of file whose edits grant
147
+ * capability, so it needs the same per-file governance as a node.
148
+ *
149
+ * This is the CORE set. An overlay that ships its own whole-document
150
+ * configuration files adds their extensions through
151
+ * {@link registerAccessFrontmatterExtensions} — the grammar is core's, the
152
+ * file kinds are not necessarily.
141
153
  */
142
- export const ACCESS_FRONTMATTER_EXTENSIONS = ['.md', '.tool'] as const;
154
+ const CORE_ACCESS_FRONTMATTER_EXTENSIONS = ['.md', '.tool'] as const;
155
+
156
+ const accessFrontmatterExtensions = new Set<string>(CORE_ACCESS_FRONTMATTER_EXTENSIONS);
143
157
 
144
- /** True when `p` is a file the resolver reads access frontmatter from. */
158
+ /**
159
+ * Extend the set of files whose own frontmatter carries access verbs.
160
+ *
161
+ * For overlays whose configuration files follow the same whole-document
162
+ * convention and, like a `.tool`, grant capability by being edited. Call once
163
+ * at boot, BEFORE any access resolution or reference scan — the declarations
164
+ * scan and the reference scanner must agree on the set, and a scanner that
165
+ * learned a new extension after a scan would leave a live grant unrewritten
166
+ * on the next rename.
167
+ *
168
+ * Idempotent, and additive only: an extension cannot be removed, because
169
+ * removing one would silently drop grants that are already enforced.
170
+ */
171
+ export function registerAccessFrontmatterExtensions(extensions: readonly string[]): void {
172
+ // Validate EVERYTHING first, then apply. These registrations decide which
173
+ // files' edits carry enforced access grants, so a typo (`pipeline` for
174
+ // `.pipeline`) must THROW rather than be skipped — a silent skip makes a
175
+ // failed registration indistinguishable from a successful one, at boot,
176
+ // where nobody is looking. And it must throw before the registry changes:
177
+ // a list that is half-applied when it throws leaves later scans governing a
178
+ // set nobody asked for.
179
+ const normalized = extensions.map((ext) => {
180
+ const n = ext.trim().toLowerCase();
181
+ if (!/^\.[a-z0-9]+$/.test(n)) {
182
+ throw new Error(
183
+ `access frontmatter extension "${ext}" is malformed — expected a leading dot, e.g. ".pipeline"`,
184
+ );
185
+ }
186
+ return n;
187
+ });
188
+ for (const n of normalized) accessFrontmatterExtensions.add(n);
189
+ }
190
+
191
+ /** Every extension currently in the set, core's plus any an overlay registered. */
192
+ export function accessFrontmatterExtensionList(): string[] {
193
+ return [...accessFrontmatterExtensions];
194
+ }
195
+
196
+ /**
197
+ * True when `p` is a file the resolver reads access frontmatter from.
198
+ *
199
+ * Case-SENSITIVE on the path, as it always was: `doc.MD` is not a node and
200
+ * carries no enforced grant. Registered extensions are normalized to lowercase
201
+ * so `.Pipeline` and `.pipeline` register the same thing, but which FILES are
202
+ * governed must not change because the set became registrable — widening it to
203
+ * `X.MD` silently in a release would be an access-model change nobody asked
204
+ * for, made in a refactor.
205
+ */
145
206
  export function hasAccessFrontmatterExtension(p: string): boolean {
146
- return ACCESS_FRONTMATTER_EXTENSIONS.some((ext) => p.endsWith(ext));
207
+ for (const ext of accessFrontmatterExtensions) {
208
+ if (p.endsWith(ext)) return true;
209
+ }
210
+ return false;
147
211
  }
148
212
  /**
149
213
  * The PRINCIPAL index — canonical name → member emails. Despite the name it