@indigoai-us/hq-cloud 6.11.11 → 6.11.13

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 (220) hide show
  1. package/dist/bin/sync-runner-company.d.ts +35 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -0
  3. package/dist/bin/sync-runner-company.js +290 -0
  4. package/dist/bin/sync-runner-company.js.map +1 -0
  5. package/dist/bin/sync-runner-events.d.ts +12 -0
  6. package/dist/bin/sync-runner-events.d.ts.map +1 -0
  7. package/dist/bin/sync-runner-events.js +12 -0
  8. package/dist/bin/sync-runner-events.js.map +1 -0
  9. package/dist/bin/sync-runner-planning.d.ts +53 -0
  10. package/dist/bin/sync-runner-planning.d.ts.map +1 -0
  11. package/dist/bin/sync-runner-planning.js +59 -0
  12. package/dist/bin/sync-runner-planning.js.map +1 -0
  13. package/dist/bin/sync-runner-rollup.d.ts +24 -0
  14. package/dist/bin/sync-runner-rollup.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-rollup.js +46 -0
  16. package/dist/bin/sync-runner-rollup.js.map +1 -0
  17. package/dist/bin/sync-runner-telemetry.d.ts +5 -0
  18. package/dist/bin/sync-runner-telemetry.d.ts.map +1 -0
  19. package/dist/bin/sync-runner-telemetry.js +5 -0
  20. package/dist/bin/sync-runner-telemetry.js.map +1 -0
  21. package/dist/bin/sync-runner-watch-loop.d.ts +17 -0
  22. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -0
  23. package/dist/bin/sync-runner-watch-loop.js +372 -0
  24. package/dist/bin/sync-runner-watch-loop.js.map +1 -0
  25. package/dist/bin/sync-runner-watch-routes.d.ts +25 -0
  26. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -0
  27. package/dist/bin/sync-runner-watch-routes.js +74 -0
  28. package/dist/bin/sync-runner-watch-routes.js.map +1 -0
  29. package/dist/bin/sync-runner.d.ts +5 -54
  30. package/dist/bin/sync-runner.d.ts.map +1 -1
  31. package/dist/bin/sync-runner.js +76 -978
  32. package/dist/bin/sync-runner.js.map +1 -1
  33. package/dist/bin/sync-runner.test.js +265 -11
  34. package/dist/bin/sync-runner.test.js.map +1 -1
  35. package/dist/cli/reindex.d.ts.map +1 -1
  36. package/dist/cli/reindex.js +34 -17
  37. package/dist/cli/reindex.js.map +1 -1
  38. package/dist/cli/reindex.test.js +39 -5
  39. package/dist/cli/reindex.test.js.map +1 -1
  40. package/dist/cli/rescue-classify-ordering.test.js +75 -0
  41. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  42. package/dist/cli/rescue-core.d.ts +45 -0
  43. package/dist/cli/rescue-core.d.ts.map +1 -1
  44. package/dist/cli/rescue-core.js +320 -170
  45. package/dist/cli/rescue-core.js.map +1 -1
  46. package/dist/cli/share.d.ts +2 -1
  47. package/dist/cli/share.d.ts.map +1 -1
  48. package/dist/cli/share.js +276 -660
  49. package/dist/cli/share.js.map +1 -1
  50. package/dist/cli/share.test.js +30 -0
  51. package/dist/cli/share.test.js.map +1 -1
  52. package/dist/cli/sync.d.ts +28 -1
  53. package/dist/cli/sync.d.ts.map +1 -1
  54. package/dist/cli/sync.js +541 -748
  55. package/dist/cli/sync.js.map +1 -1
  56. package/dist/cli/sync.test.js +382 -1
  57. package/dist/cli/sync.test.js.map +1 -1
  58. package/dist/cognito-auth.d.ts.map +1 -1
  59. package/dist/cognito-auth.js +55 -10
  60. package/dist/cognito-auth.js.map +1 -1
  61. package/dist/cognito-auth.test.js +61 -0
  62. package/dist/cognito-auth.test.js.map +1 -1
  63. package/dist/daemon-worker.d.ts +2 -2
  64. package/dist/daemon-worker.js +3 -3
  65. package/dist/daemon-worker.js.map +1 -1
  66. package/dist/index.d.ts +2 -1
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/journal.d.ts.map +1 -1
  71. package/dist/journal.js +93 -6
  72. package/dist/journal.js.map +1 -1
  73. package/dist/journal.test.js +59 -0
  74. package/dist/journal.test.js.map +1 -1
  75. package/dist/machine-auth.test.js +60 -2
  76. package/dist/machine-auth.test.js.map +1 -1
  77. package/dist/object-io.d.ts +37 -1
  78. package/dist/object-io.d.ts.map +1 -1
  79. package/dist/object-io.js +149 -30
  80. package/dist/object-io.js.map +1 -1
  81. package/dist/object-io.test.js +121 -0
  82. package/dist/object-io.test.js.map +1 -1
  83. package/dist/operation-lock.d.ts +8 -8
  84. package/dist/operation-lock.d.ts.map +1 -1
  85. package/dist/operation-lock.js +99 -32
  86. package/dist/operation-lock.js.map +1 -1
  87. package/dist/operation-lock.test.js +51 -4
  88. package/dist/operation-lock.test.js.map +1 -1
  89. package/dist/personal-vault.d.ts.map +1 -1
  90. package/dist/personal-vault.js +8 -2
  91. package/dist/personal-vault.js.map +1 -1
  92. package/dist/personal-vault.test.js +34 -0
  93. package/dist/personal-vault.test.js.map +1 -1
  94. package/dist/prefix-coalesce.d.ts +20 -9
  95. package/dist/prefix-coalesce.d.ts.map +1 -1
  96. package/dist/prefix-coalesce.js +124 -28
  97. package/dist/prefix-coalesce.js.map +1 -1
  98. package/dist/prefix-coalesce.test.js +57 -2
  99. package/dist/prefix-coalesce.test.js.map +1 -1
  100. package/dist/remote-pull.d.ts +8 -3
  101. package/dist/remote-pull.d.ts.map +1 -1
  102. package/dist/remote-pull.js +85 -16
  103. package/dist/remote-pull.js.map +1 -1
  104. package/dist/remote-pull.test.js +213 -2
  105. package/dist/remote-pull.test.js.map +1 -1
  106. package/dist/s3.d.ts +2 -0
  107. package/dist/s3.d.ts.map +1 -1
  108. package/dist/s3.js +197 -116
  109. package/dist/s3.js.map +1 -1
  110. package/dist/s3.test.js +109 -0
  111. package/dist/s3.test.js.map +1 -1
  112. package/dist/scope-shrink.d.ts +3 -2
  113. package/dist/scope-shrink.d.ts.map +1 -1
  114. package/dist/scope-shrink.js +1 -1
  115. package/dist/scope-shrink.js.map +1 -1
  116. package/dist/skill-telemetry.d.ts +1 -1
  117. package/dist/skill-telemetry.d.ts.map +1 -1
  118. package/dist/skill-telemetry.js +69 -9
  119. package/dist/skill-telemetry.js.map +1 -1
  120. package/dist/skill-telemetry.test.js +86 -0
  121. package/dist/skill-telemetry.test.js.map +1 -1
  122. package/dist/sync/event-sync.d.ts +6 -0
  123. package/dist/sync/event-sync.d.ts.map +1 -1
  124. package/dist/sync/event-sync.js +34 -1
  125. package/dist/sync/event-sync.js.map +1 -1
  126. package/dist/sync/event-sync.test.js +73 -0
  127. package/dist/sync/event-sync.test.js.map +1 -1
  128. package/dist/sync/metrics.d.ts +17 -1
  129. package/dist/sync/metrics.d.ts.map +1 -1
  130. package/dist/sync/metrics.js +32 -1
  131. package/dist/sync/metrics.js.map +1 -1
  132. package/dist/sync/metrics.test.js +74 -1
  133. package/dist/sync/metrics.test.js.map +1 -1
  134. package/dist/sync/pull-scope.d.ts.map +1 -1
  135. package/dist/sync/pull-scope.js +15 -7
  136. package/dist/sync/pull-scope.js.map +1 -1
  137. package/dist/sync/push-receiver.d.ts +12 -5
  138. package/dist/sync/push-receiver.d.ts.map +1 -1
  139. package/dist/sync/push-receiver.js +45 -17
  140. package/dist/sync/push-receiver.js.map +1 -1
  141. package/dist/sync/push-receiver.test.js +67 -1
  142. package/dist/sync/push-receiver.test.js.map +1 -1
  143. package/dist/sync-core.d.ts +27 -0
  144. package/dist/sync-core.d.ts.map +1 -0
  145. package/dist/sync-core.js +54 -0
  146. package/dist/sync-core.js.map +1 -0
  147. package/dist/telemetry.d.ts +1 -1
  148. package/dist/telemetry.d.ts.map +1 -1
  149. package/dist/telemetry.js +59 -6
  150. package/dist/telemetry.js.map +1 -1
  151. package/dist/telemetry.test.js +74 -0
  152. package/dist/telemetry.test.js.map +1 -1
  153. package/dist/types.d.ts +8 -0
  154. package/dist/types.d.ts.map +1 -1
  155. package/dist/vault-client.d.ts.map +1 -1
  156. package/dist/vault-client.js +284 -36
  157. package/dist/vault-client.js.map +1 -1
  158. package/dist/vault-client.test.js +59 -0
  159. package/dist/vault-client.test.js.map +1 -1
  160. package/dist/watcher.d.ts +38 -20
  161. package/dist/watcher.d.ts.map +1 -1
  162. package/dist/watcher.js +155 -143
  163. package/dist/watcher.js.map +1 -1
  164. package/dist/watcher.test.js +103 -0
  165. package/dist/watcher.test.js.map +1 -1
  166. package/package.json +1 -1
  167. package/src/bin/sync-runner-company.ts +350 -0
  168. package/src/bin/sync-runner-events.ts +25 -0
  169. package/src/bin/sync-runner-planning.ts +121 -0
  170. package/src/bin/sync-runner-rollup.ts +72 -0
  171. package/src/bin/sync-runner-telemetry.ts +8 -0
  172. package/src/bin/sync-runner-watch-loop.ts +443 -0
  173. package/src/bin/sync-runner-watch-routes.ts +86 -0
  174. package/src/bin/sync-runner.test.ts +298 -11
  175. package/src/bin/sync-runner.ts +99 -1054
  176. package/src/cli/reindex.test.ts +41 -3
  177. package/src/cli/reindex.ts +35 -19
  178. package/src/cli/rescue-classify-ordering.test.ts +81 -0
  179. package/src/cli/rescue-core.ts +400 -165
  180. package/src/cli/share.test.ts +38 -0
  181. package/src/cli/share.ts +420 -693
  182. package/src/cli/sync.test.ts +460 -1
  183. package/src/cli/sync.ts +788 -825
  184. package/src/cognito-auth.test.ts +77 -0
  185. package/src/cognito-auth.ts +73 -11
  186. package/src/daemon-worker.ts +3 -3
  187. package/src/index.ts +8 -0
  188. package/src/journal.test.ts +72 -0
  189. package/src/journal.ts +95 -8
  190. package/src/machine-auth.test.ts +64 -2
  191. package/src/object-io.test.ts +142 -0
  192. package/src/object-io.ts +183 -31
  193. package/src/operation-lock.test.ts +63 -4
  194. package/src/operation-lock.ts +99 -31
  195. package/src/personal-vault.test.ts +42 -0
  196. package/src/personal-vault.ts +8 -2
  197. package/src/prefix-coalesce.test.ts +71 -1
  198. package/src/prefix-coalesce.ts +155 -30
  199. package/src/remote-pull.test.ts +235 -1
  200. package/src/remote-pull.ts +106 -18
  201. package/src/s3.test.ts +126 -0
  202. package/src/s3.ts +237 -122
  203. package/src/scope-shrink.ts +6 -3
  204. package/src/skill-telemetry.test.ts +109 -0
  205. package/src/skill-telemetry.ts +82 -14
  206. package/src/sync/event-sync.test.ts +75 -0
  207. package/src/sync/event-sync.ts +54 -1
  208. package/src/sync/metrics.test.ts +81 -0
  209. package/src/sync/metrics.ts +59 -4
  210. package/src/sync/pull-scope.ts +23 -7
  211. package/src/sync/push-receiver.test.ts +73 -1
  212. package/src/sync/push-receiver.ts +56 -20
  213. package/src/sync-core.ts +58 -0
  214. package/src/telemetry.test.ts +85 -0
  215. package/src/telemetry.ts +69 -6
  216. package/src/types.ts +8 -0
  217. package/src/vault-client.test.ts +74 -0
  218. package/src/vault-client.ts +395 -43
  219. package/src/watcher.test.ts +117 -0
  220. package/src/watcher.ts +215 -174
@@ -3,6 +3,8 @@ import {
3
3
  coalescePrefixes,
4
4
  isCoveredByAny,
5
5
  grantPathToPrefix,
6
+ grantPathToScopePrefix,
7
+ toScopePrefixEntries,
6
8
  } from "./prefix-coalesce.js";
7
9
 
8
10
  describe("coalescePrefixes", () => {
@@ -74,6 +76,12 @@ describe("coalescePrefixes", () => {
74
76
  // prefixes — the grants endpoint always does.)
75
77
  expect(coalescePrefixes(["a/", "ab/"]).sort()).toEqual(["a/", "ab/"]);
76
78
  });
79
+
80
+ it("F16: keeps exact-file scopes distinct from prefixed sibling keys", () => {
81
+ expect(
82
+ coalescePrefixes(["knowledge/README.md", "knowledge/README.md.bak"]),
83
+ ).toEqual(["knowledge/README.md", "knowledge/README.md.bak"]);
84
+ });
77
85
  });
78
86
 
79
87
  describe("isCoveredByAny", () => {
@@ -121,7 +129,11 @@ describe("grantPathToPrefix", () => {
121
129
  });
122
130
 
123
131
  it("folds a trailing bare * into its parent prefix", () => {
124
- expect(grantPathToPrefix("reports*", slug)).toBe("reports");
132
+ const prefix = grantPathToPrefix("reports*", slug);
133
+ expect(toScopePrefixEntries([prefix])).toEqual([
134
+ { prefix: "reports", match: "prefix" },
135
+ ]);
136
+ expect(isCoveredByAny("reports-q1.md", [prefix])).toBe(true);
125
137
  });
126
138
 
127
139
  it("leaves a company-relative directory prefix unchanged", () => {
@@ -167,4 +179,62 @@ describe("grantPathToPrefix", () => {
167
179
  expect(isCoveredByAny("secrets/db.json", prefixes)).toBe(false);
168
180
  expect(isCoveredByAny("knowledge/OTHER.md", prefixes)).toBe(false);
169
181
  });
182
+
183
+ it("F16: exact-file grants do not authorize prefixed sibling keys", () => {
184
+ const grants = [
185
+ "companies/indigo/knowledge/README.md",
186
+ "company.yaml",
187
+ "data/vyg/*",
188
+ ];
189
+ const prefixes = coalescePrefixes(grants.map((g) => grantPathToPrefix(g, slug)));
190
+
191
+ expect(isCoveredByAny("knowledge/README.md", prefixes)).toBe(true);
192
+ expect(isCoveredByAny("company.yaml", prefixes)).toBe(true);
193
+ expect(isCoveredByAny("data/vyg/2026/q1.md", prefixes)).toBe(true);
194
+
195
+ expect(isCoveredByAny("knowledge/README.md.bak", prefixes)).toBe(false);
196
+ expect(isCoveredByAny("company.yaml.tmp", prefixes)).toBe(false);
197
+ });
198
+
199
+ it("R-F16: exact scope is deterministic after a prior bare-glob scope", () => {
200
+ expect(toScopePrefixEntries([grantPathToPrefix("companies/indigo/foo*", slug)])).toEqual([
201
+ { prefix: "foo", match: "prefix" },
202
+ ]);
203
+
204
+ const exactPrefixes = coalescePrefixes([
205
+ grantPathToPrefix("companies/other/foo", "other"),
206
+ ]);
207
+
208
+ expect(isCoveredByAny("foo", exactPrefixes)).toBe(true);
209
+ expect(isCoveredByAny("foo.bak", exactPrefixes)).toBe(false);
210
+ });
211
+
212
+ it("RF-F16DUR: exact-vs-prefix survives array copy and JSON round-trip", () => {
213
+ const priorBareGlob = coalescePrefixes([
214
+ grantPathToScopePrefix("companies/indigo/foo*", slug),
215
+ ]);
216
+ const copiedBareGlob = [...priorBareGlob];
217
+ const jsonBareGlob = JSON.parse(JSON.stringify(priorBareGlob)) as string[];
218
+
219
+ expect(jsonBareGlob).toEqual(priorBareGlob);
220
+ expect(toScopePrefixEntries(copiedBareGlob)).toEqual([
221
+ { prefix: "foo", match: "prefix" },
222
+ ]);
223
+ expect(toScopePrefixEntries(jsonBareGlob)).toEqual([
224
+ { prefix: "foo", match: "prefix" },
225
+ ]);
226
+ expect(isCoveredByAny("foo.bak", copiedBareGlob)).toBe(true);
227
+ expect(isCoveredByAny("foo.bak", jsonBareGlob)).toBe(true);
228
+
229
+ const exactAfterPriorGlob = coalescePrefixes([
230
+ grantPathToScopePrefix("companies/indigo/foo", slug),
231
+ ]);
232
+ const jsonExact = JSON.parse(JSON.stringify(exactAfterPriorGlob)) as string[];
233
+
234
+ expect(toScopePrefixEntries(jsonExact)).toEqual([
235
+ { prefix: "foo", match: "exact" },
236
+ ]);
237
+ expect(isCoveredByAny("foo", jsonExact)).toBe(true);
238
+ expect(isCoveredByAny("foo.bak", jsonExact)).toBe(false);
239
+ });
170
240
  });
@@ -21,18 +21,138 @@
21
21
  * - **Determinism:** output is sorted lexicographically so the journal's
22
22
  * `prefixSet` stays diff-stable across runs.
23
23
  *
24
- * Coverage rule: `a` covers `b` iff `a === b` OR `b.startsWith(a)`. This is a
25
- * literal string-prefix relation it does NOT understand S3 "folders". A
26
- * caller that wants `a/` to NOT cover `ab/` must pass trailing-slash-bounded
27
- * prefixes (the grants endpoint already does every ACL row ends in `/`).
24
+ * Coverage rule: exact-file entries cover only themselves. Directory prefixes
25
+ * (ending in `/`) and bare glob prefixes cover descendants with literal
26
+ * `startsWith` matching. When a string is ambiguous (e.g. `foo` from `foo*`,
27
+ * which must be a prefix, not an exact file), the coalesced `prefixSet` stores
28
+ * a small in-band marker in the string itself so the match type survives
29
+ * `[...prefixSet]` copies and JSON persistence.
28
30
  */
29
31
 
30
- export function coalescePrefixes(prefixes: readonly string[]): string[] {
31
- // Dedup + drop empties.
32
- const unique = new Set<string>();
32
+ export type ScopePrefixMatch = "exact" | "prefix";
33
+
34
+ export interface ScopePrefixEntry {
35
+ prefix: string;
36
+ match: ScopePrefixMatch;
37
+ }
38
+
39
+ export type ScopePrefixInput = string | ScopePrefixEntry;
40
+
41
+ const SCOPE_PREFIX_MARKER = "\u0000hq-scope:";
42
+ const SCOPE_PREFIX_MARKER_PREFIX = `${SCOPE_PREFIX_MARKER}prefix`;
43
+ const SCOPE_PREFIX_MARKER_EXACT = `${SCOPE_PREFIX_MARKER}exact`;
44
+
45
+ function isScopePrefixEntry(value: ScopePrefixInput): value is ScopePrefixEntry {
46
+ return typeof value !== "string";
47
+ }
48
+
49
+ function inferStringMatch(prefix: string): ScopePrefixMatch {
50
+ return prefix === "" || prefix.endsWith("/") ? "prefix" : "exact";
51
+ }
52
+
53
+ function parseStringPrefix(input: string): ScopePrefixEntry {
54
+ if (input.endsWith(SCOPE_PREFIX_MARKER_PREFIX)) {
55
+ return {
56
+ prefix: input.slice(0, -SCOPE_PREFIX_MARKER_PREFIX.length),
57
+ match: "prefix",
58
+ };
59
+ }
60
+ if (input.endsWith(SCOPE_PREFIX_MARKER_EXACT)) {
61
+ return {
62
+ prefix: input.slice(0, -SCOPE_PREFIX_MARKER_EXACT.length),
63
+ match: "exact",
64
+ };
65
+ }
66
+ return {
67
+ prefix: input,
68
+ match: inferStringMatch(input),
69
+ };
70
+ }
71
+
72
+ function encodeScopePrefix(entry: ScopePrefixEntry): string {
73
+ return entry.match === inferStringMatch(entry.prefix)
74
+ ? entry.prefix
75
+ : `${entry.prefix}${SCOPE_PREFIX_MARKER}${entry.match}`;
76
+ }
77
+
78
+ function normalizeScopePrefix(input: ScopePrefixInput): ScopePrefixEntry | null {
79
+ if (typeof input === "string") {
80
+ return parseStringPrefix(input);
81
+ }
82
+ if (typeof input.prefix !== "string") return null;
83
+ return {
84
+ prefix: input.prefix,
85
+ match: input.match === "prefix" ? "prefix" : "exact",
86
+ };
87
+ }
88
+
89
+ function materializeScopePrefixes(
90
+ prefixes: readonly ScopePrefixInput[],
91
+ ): ScopePrefixEntry[] {
92
+ const entries: ScopePrefixEntry[] = [];
33
93
  for (const p of prefixes) {
34
- if (typeof p === "string" && p !== "") {
35
- unique.add(p);
94
+ const entry = normalizeScopePrefix(p);
95
+ if (entry) entries.push(entry);
96
+ }
97
+ return entries;
98
+ }
99
+
100
+ function compareEntries(a: ScopePrefixEntry, b: ScopePrefixEntry): number {
101
+ if (a.prefix < b.prefix) return -1;
102
+ if (a.prefix > b.prefix) return 1;
103
+ if (a.match === b.match) return 0;
104
+ return a.match === "prefix" ? -1 : 1;
105
+ }
106
+
107
+ function entryCoversPath(entry: ScopePrefixEntry, key: string): boolean {
108
+ if (entry.prefix === "") return true;
109
+ if (key === entry.prefix) return true;
110
+ return entry.match === "prefix" && key.startsWith(entry.prefix);
111
+ }
112
+
113
+ function entryCoversEntry(
114
+ entry: ScopePrefixEntry,
115
+ candidate: ScopePrefixEntry,
116
+ ): boolean {
117
+ if (entry.prefix === "") return true;
118
+ if (entry.match === "prefix") {
119
+ return (
120
+ candidate.prefix === entry.prefix ||
121
+ candidate.prefix.startsWith(entry.prefix)
122
+ );
123
+ }
124
+ return candidate.match === "exact" && candidate.prefix === entry.prefix;
125
+ }
126
+
127
+ export function toScopePrefixEntries(
128
+ prefixSet: readonly ScopePrefixInput[],
129
+ ): ScopePrefixEntry[] {
130
+ return materializeScopePrefixes(prefixSet);
131
+ }
132
+
133
+ export function pathToScopePrefix(path: string): ScopePrefixEntry {
134
+ const p = (path ?? "").replace(/^\/+/, "");
135
+ if (
136
+ p.endsWith(SCOPE_PREFIX_MARKER_PREFIX) ||
137
+ p.endsWith(SCOPE_PREFIX_MARKER_EXACT)
138
+ ) {
139
+ return parseStringPrefix(p);
140
+ }
141
+ if (p === "*" || p === "") return { prefix: "", match: "prefix" };
142
+ if (p.endsWith("/*")) return { prefix: p.slice(0, -1), match: "prefix" };
143
+ if (p.endsWith("*")) return { prefix: p.slice(0, -1), match: "prefix" };
144
+ if (p.endsWith("/")) return { prefix: p, match: "prefix" };
145
+ return { prefix: p, match: "exact" };
146
+ }
147
+
148
+ export function coalescePrefixes(
149
+ prefixes: readonly ScopePrefixInput[],
150
+ ): string[] {
151
+ // Dedup + drop empties.
152
+ const unique = new Map<string, ScopePrefixEntry>();
153
+ for (const entry of materializeScopePrefixes(prefixes)) {
154
+ if (entry.prefix !== "") {
155
+ unique.set(`${entry.match}\0${entry.prefix}`, entry);
36
156
  }
37
157
  }
38
158
  if (unique.size === 0) return [];
@@ -40,33 +160,33 @@ export function coalescePrefixes(prefixes: readonly string[]): string[] {
40
160
  // Sort lexicographically so a broader prefix (`a/`) appears before its
41
161
  // narrower descendants (`a/b/`). Then a single pass keeps the broadest in
42
162
  // each cover chain.
43
- const sorted = [...unique].sort();
44
- const result: string[] = [];
45
- let lastKept: string | null = null;
46
- for (const p of sorted) {
47
- if (lastKept !== null && p.startsWith(lastKept)) {
163
+ const sorted = [...unique.values()].sort(compareEntries);
164
+ const result: ScopePrefixEntry[] = [];
165
+ let lastKept: ScopePrefixEntry | null = null;
166
+ for (const entry of sorted) {
167
+ if (lastKept !== null && entryCoversEntry(lastKept, entry)) {
48
168
  // `lastKept` already covers `p`; skip.
49
169
  continue;
50
170
  }
51
- result.push(p);
52
- lastKept = p;
171
+ result.push(entry);
172
+ lastKept = entry;
53
173
  }
54
- return result;
174
+ return result.map(encodeScopePrefix);
55
175
  }
56
176
 
57
177
  /**
58
178
  * Predicate companion: does any prefix in `prefixSet` cover `path`?
59
179
  *
60
180
  * Used by the journal scope-shrink algorithm to test whether a journaled
61
- * file is still in scope under the current pull's `prefixSet`. Same
62
- * `startsWith` semantics as `coalescePrefixes`.
181
+ * file is still in scope under the current pull's `prefixSet`. Exact-file
182
+ * entries match only themselves; directory/glob prefixes use `startsWith`.
63
183
  */
64
184
  export function isCoveredByAny(
65
185
  path: string,
66
- prefixSet: readonly string[],
186
+ prefixSet: readonly ScopePrefixInput[],
67
187
  ): boolean {
68
- for (const p of prefixSet) {
69
- if (p === "" || path.startsWith(p)) return true;
188
+ for (const entry of materializeScopePrefixes(prefixSet)) {
189
+ if (entryCoversPath(entry, path)) return true;
70
190
  }
71
191
  return false;
72
192
  }
@@ -94,13 +214,14 @@ export function isCoveredByAny(
94
214
  */
95
215
  export function isDirInScope(
96
216
  relDir: string,
97
- prefixSet: readonly string[],
217
+ prefixSet: readonly ScopePrefixInput[],
98
218
  ): boolean {
99
219
  const dir = relDir === "" || relDir.endsWith("/") ? relDir : relDir + "/";
100
- for (const p of prefixSet) {
220
+ for (const entry of materializeScopePrefixes(prefixSet)) {
221
+ const p = entry.prefix;
101
222
  if (p === "") return true; // full scope
102
223
  if (dir === "") return true; // company root — descend to reach grants
103
- if (dir.startsWith(p)) return true; // dir is inside a granted prefix
224
+ if (entry.match === "prefix" && dir.startsWith(p)) return true; // dir is inside a granted prefix
104
225
  if (p.startsWith(dir)) return true; // a granted prefix is inside dir
105
226
  }
106
227
  return false;
@@ -134,7 +255,10 @@ export function isDirInScope(
134
255
  * `syncMode: shared` would download nothing and prune everything the caller
135
256
  * actually has access to. See the live grant dump in the hq-pro vault.
136
257
  */
137
- export function grantPathToPrefix(grantPath: string, slug: string): string {
258
+ export function grantPathToScopePrefix(
259
+ grantPath: string,
260
+ slug: string,
261
+ ): ScopePrefixEntry {
138
262
  let p = (grantPath ?? "").replace(/^\/+/, "");
139
263
  // 1. Strip the company anchor, if present.
140
264
  const companyAnchor = `companies/${slug}/`;
@@ -145,8 +269,9 @@ export function grantPathToPrefix(grantPath: string, slug: string): string {
145
269
  p = p.slice(slugAnchor.length);
146
270
  }
147
271
  // 2. Fold the ACL glob suffix into a startsWith prefix.
148
- if (p === "*" || p === "") return ""; // company-wide / empty → everything
149
- if (p.endsWith("/*")) return p.slice(0, -1); // "a/b/*" → "a/b/"
150
- if (p.endsWith("*")) return p.slice(0, -1); // "a/b*" → "a/b"
151
- return p; // dir prefix ("a/b/") or exact key ("a/b.md") already a prefix
272
+ return pathToScopePrefix(p);
273
+ }
274
+
275
+ export function grantPathToPrefix(grantPath: string, slug: string): string {
276
+ return encodeScopePrefix(grantPathToScopePrefix(grantPath, slug));
152
277
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Failing-test seed for the Auto-sync (Beta) remote-pull loop.
3
3
  *
4
- * Background: SyncWatcher (watcher.ts) pushes local edits to S3 in seconds,
4
+ * Background: the watcher push path ships local edits to S3 in seconds,
5
5
  * but pulls happen only on a manual sync today. Auto-sync adds a periodic
6
6
  * (every 10 min) remote-pull pass per company. The decision of *which* keys
7
7
  * to download / delete locally / skip is pure given a remote listing, the
@@ -25,6 +25,7 @@ import {
25
25
  POST_FILTER_THRESHOLD,
26
26
  pullCompany,
27
27
  resolveCompanyScope,
28
+ VEND_FANOUT_CONCURRENCY,
28
29
  VEND_PATH_CAP,
29
30
  } from "./remote-pull.js";
30
31
  import type { RemoteFile } from "./s3.js";
@@ -157,6 +158,111 @@ describe("decideRemotePulls", () => {
157
158
  expect(result.download).toEqual([]);
158
159
  });
159
160
 
161
+ it("F07: excludes scope-shrink tombstones from remote-delete decisions", () => {
162
+ const journal: SyncJournal = {
163
+ version: "2",
164
+ lastSync: "2026-05-01T00:00:00Z",
165
+ files: {
166
+ "docs/scope-shrunk-dirty.md": {
167
+ hash: "dirty-before-shrink",
168
+ size: 42,
169
+ syncedAt: "2026-05-01T00:00:00Z",
170
+ direction: "down",
171
+ remoteEtag: "old-dirty",
172
+ removedAt: "2026-05-02T00:00:00Z",
173
+ removedReason: "scope_shrink",
174
+ },
175
+ "docs/live-gone.md": {
176
+ hash: "h",
177
+ size: 100,
178
+ syncedAt: "2026-05-01T00:00:00Z",
179
+ direction: "down",
180
+ remoteEtag: "old-live",
181
+ },
182
+ },
183
+ pulls: [],
184
+ };
185
+ const result = decideRemotePulls({
186
+ remoteFiles: [],
187
+ journal,
188
+ conflictKeys: new Set(),
189
+ });
190
+ expect(result.deleteLocal).toEqual(["docs/live-gone.md"]);
191
+ expect(result.download).toEqual([]);
192
+ });
193
+
194
+ it("R-F07: excludes scope-shrink-protected survivors from remote-delete decisions", async () => {
195
+ const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rf07-"));
196
+ try {
197
+ const journal: SyncJournal = {
198
+ version: "2",
199
+ lastSync: "2026-05-01T00:00:00Z",
200
+ files: {
201
+ "companies/indigo/projects/mine.md": {
202
+ hash: "h-mine",
203
+ size: 6,
204
+ syncedAt: "2026-05-01T00:00:00Z",
205
+ direction: "down",
206
+ remoteEtag: "old-mine",
207
+ createdBySub: "owner-sub",
208
+ },
209
+ "companies/indigo/projects/legacy.md": {
210
+ hash: "h-legacy",
211
+ size: 6,
212
+ syncedAt: "2026-05-01T00:00:00Z",
213
+ direction: "down",
214
+ remoteEtag: "old-legacy",
215
+ },
216
+ "companies/indigo/meetings/gone.md": {
217
+ hash: "h-gone",
218
+ size: 4,
219
+ syncedAt: "2026-05-01T00:00:00Z",
220
+ direction: "down",
221
+ remoteEtag: "old-gone",
222
+ createdBySub: "peer-sub",
223
+ },
224
+ },
225
+ pulls: [
226
+ {
227
+ pullId: "01PREV",
228
+ companyUid: "cmp_indigo",
229
+ startedAt: "2026-05-19T00:00:00.000Z",
230
+ completedAt: "2026-05-19T00:00:05.000Z",
231
+ syncMode: "all",
232
+ prefixSet: ["companies/indigo/"],
233
+ scopeChangeDetected: false,
234
+ orphansRemoved: 0,
235
+ orphansBlocked: 0,
236
+ },
237
+ ],
238
+ };
239
+
240
+ const result = await pullCompany({
241
+ ctx: makeCtx(),
242
+ journal,
243
+ hqRoot,
244
+ callerSub: "owner-sub",
245
+ scope: {
246
+ companyUid: "cmp_indigo",
247
+ syncMode: "shared",
248
+ prefixSet: ["companies/indigo/meetings/"],
249
+ strategy: "vend-fanout",
250
+ },
251
+ listFn: async () => [],
252
+ });
253
+
254
+ expect(result.decision.deleteLocal).toEqual([
255
+ "companies/indigo/meetings/gone.md",
256
+ ]);
257
+ expect(result.decision.skip.map((f) => f.key).sort()).toEqual([
258
+ "companies/indigo/projects/legacy.md",
259
+ "companies/indigo/projects/mine.md",
260
+ ]);
261
+ } finally {
262
+ fs.rmSync(hqRoot, { recursive: true, force: true });
263
+ }
264
+ });
265
+
160
266
  it("never deletes locally for entries that have NEVER been synced down", () => {
161
267
  // Push-only entries (direction: 'up') represent files we created locally
162
268
  // and uploaded. If the user later deletes them remotely from another
@@ -474,6 +580,34 @@ describe("listRemoteForScope", () => {
474
580
  expect(calls.size).toBe(VEND_PATH_CAP + 3); // every prefix listed
475
581
  });
476
582
 
583
+ it("F28: bounds concurrent per-prefix listings across vend-fanout batches", async () => {
584
+ const prefixes = Array.from(
585
+ { length: POST_FILTER_THRESHOLD },
586
+ (_, i) => `companies/indigo/p${i}/`,
587
+ );
588
+ let active = 0;
589
+ let maxActive = 0;
590
+
591
+ await listRemoteForScope({
592
+ ctx: makeCtx(),
593
+ scope: {
594
+ companyUid: "cmp_indigo",
595
+ syncMode: "shared",
596
+ prefixSet: prefixes,
597
+ strategy: "vend-fanout",
598
+ },
599
+ listFn: async () => {
600
+ active += 1;
601
+ maxActive = Math.max(maxActive, active);
602
+ await new Promise((resolve) => setTimeout(resolve, 1));
603
+ active -= 1;
604
+ return [];
605
+ },
606
+ });
607
+
608
+ expect(maxActive).toBeLessThanOrEqual(VEND_FANOUT_CONCURRENCY);
609
+ });
610
+
477
611
  it("strategy=vend-fanout uses vendForBatchFn to narrow credentials per batch", async () => {
478
612
  const vendCalls: Array<{ paths: string[] }> = [];
479
613
  await listRemoteForScope({
@@ -521,6 +655,28 @@ describe("listRemoteForScope", () => {
521
655
  ]);
522
656
  });
523
657
 
658
+ it("R-F16: strategy=vend-fanout post-filters exact-grant siblings", async () => {
659
+ const scope = resolveCompanyScope({
660
+ companyUid: "cmp_indigo",
661
+ companyPrefix: "companies/indigo/",
662
+ syncConfig: makeSyncConfig({ syncMode: "shared" }),
663
+ explicitGrants: [makeGrant("companies/indigo/README.md")],
664
+ });
665
+ const files = await listRemoteForScope({
666
+ ctx: makeCtx(),
667
+ scope,
668
+ listFn: async (_ctx, prefix) => {
669
+ expect(prefix).toBe("companies/indigo/README.md");
670
+ return [
671
+ remote({ key: "companies/indigo/README.md" }),
672
+ remote({ key: "companies/indigo/README.md.bak" }),
673
+ ];
674
+ },
675
+ });
676
+
677
+ expect(files.map((f) => f.key)).toEqual(["companies/indigo/README.md"]);
678
+ });
679
+
524
680
  it("strategy=vend-fanout short-circuits to [] on empty prefixSet", async () => {
525
681
  let listed = false;
526
682
  const files = await listRemoteForScope({
@@ -859,6 +1015,84 @@ describe("pullCompany (engine orchestrator)", () => {
859
1015
  expect(fs.existsSync(legacyAbs)).toBe(true); // legacy file retained
860
1016
  });
861
1017
 
1018
+ it("RF-F07DUR: scope-shrink-protected survivors are excluded on a second pull", async () => {
1019
+ const journal: SyncJournal = {
1020
+ version: "2",
1021
+ lastSync: "",
1022
+ files: {
1023
+ "companies/indigo/projects/legacy.md": {
1024
+ hash: "h-legacy",
1025
+ size: 6,
1026
+ syncedAt: "2026-05-01T00:00:00.000Z",
1027
+ direction: "down",
1028
+ remoteEtag: "old-legacy",
1029
+ },
1030
+ "companies/indigo/meetings/gone.md": {
1031
+ hash: "h-gone",
1032
+ size: 4,
1033
+ syncedAt: "2026-05-01T00:00:00.000Z",
1034
+ direction: "down",
1035
+ remoteEtag: "old-gone",
1036
+ createdBySub: "peer-sub",
1037
+ },
1038
+ },
1039
+ pulls: [
1040
+ {
1041
+ pullId: "01PREV",
1042
+ companyUid: "cmp_indigo",
1043
+ startedAt: "2026-05-19T00:00:00.000Z",
1044
+ completedAt: "2026-05-19T00:00:05.000Z",
1045
+ syncMode: "all",
1046
+ prefixSet: ["companies/indigo/"],
1047
+ scopeChangeDetected: false,
1048
+ orphansRemoved: 0,
1049
+ orphansBlocked: 0,
1050
+ },
1051
+ ],
1052
+ };
1053
+ const narrowedScope = {
1054
+ companyUid: "cmp_indigo",
1055
+ syncMode: "shared" as const,
1056
+ prefixSet: ["companies/indigo/meetings/"],
1057
+ strategy: "vend-fanout" as const,
1058
+ };
1059
+
1060
+ const first = await pullCompany({
1061
+ ctx: makeCtx(),
1062
+ journal,
1063
+ hqRoot,
1064
+ callerSub: "owner-sub",
1065
+ scope: narrowedScope,
1066
+ listFn: async () => [],
1067
+ });
1068
+
1069
+ expect(first.decision.skip.map((f) => f.key)).toContain(
1070
+ "companies/indigo/projects/legacy.md",
1071
+ );
1072
+ expect(first.decision.deleteLocal).toEqual([
1073
+ "companies/indigo/meetings/gone.md",
1074
+ ]);
1075
+ expect(
1076
+ journal.files["companies/indigo/projects/legacy.md"]?.outOfScopeProtected,
1077
+ ).toBe(true);
1078
+
1079
+ const second = await pullCompany({
1080
+ ctx: makeCtx(),
1081
+ journal,
1082
+ hqRoot,
1083
+ callerSub: "owner-sub",
1084
+ scope: narrowedScope,
1085
+ listFn: async () => [],
1086
+ });
1087
+
1088
+ expect(second.decision.skip.map((f) => f.key)).toContain(
1089
+ "companies/indigo/projects/legacy.md",
1090
+ );
1091
+ expect(second.decision.deleteLocal).toEqual([
1092
+ "companies/indigo/meetings/gone.md",
1093
+ ]);
1094
+ });
1095
+
862
1096
  it("GC's expired tombstones at the start of every leg", async () => {
863
1097
  const old = new Date(
864
1098
  Date.now() - 31 * 24 * 60 * 60 * 1000,