@indigoai-us/hq-cloud 6.11.11 → 6.11.12

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 (160) hide show
  1. package/dist/bin/sync-runner.d.ts +2 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +231 -52
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +265 -11
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/rescue-classify-ordering.test.js +58 -0
  8. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  9. package/dist/cli/rescue-core.js +138 -15
  10. package/dist/cli/rescue-core.js.map +1 -1
  11. package/dist/cli/share.d.ts +2 -1
  12. package/dist/cli/share.d.ts.map +1 -1
  13. package/dist/cli/share.js +100 -32
  14. package/dist/cli/share.js.map +1 -1
  15. package/dist/cli/share.test.js +30 -0
  16. package/dist/cli/share.test.js.map +1 -1
  17. package/dist/cli/sync.d.ts +28 -1
  18. package/dist/cli/sync.d.ts.map +1 -1
  19. package/dist/cli/sync.js +178 -58
  20. package/dist/cli/sync.js.map +1 -1
  21. package/dist/cli/sync.test.js +362 -1
  22. package/dist/cli/sync.test.js.map +1 -1
  23. package/dist/cognito-auth.d.ts.map +1 -1
  24. package/dist/cognito-auth.js +55 -10
  25. package/dist/cognito-auth.js.map +1 -1
  26. package/dist/cognito-auth.test.js +61 -0
  27. package/dist/cognito-auth.test.js.map +1 -1
  28. package/dist/index.d.ts +2 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/journal.d.ts.map +1 -1
  33. package/dist/journal.js +93 -6
  34. package/dist/journal.js.map +1 -1
  35. package/dist/journal.test.js +59 -0
  36. package/dist/journal.test.js.map +1 -1
  37. package/dist/machine-auth.test.js +60 -2
  38. package/dist/machine-auth.test.js.map +1 -1
  39. package/dist/object-io.d.ts +37 -1
  40. package/dist/object-io.d.ts.map +1 -1
  41. package/dist/object-io.js +148 -29
  42. package/dist/object-io.js.map +1 -1
  43. package/dist/object-io.test.js +121 -0
  44. package/dist/object-io.test.js.map +1 -1
  45. package/dist/operation-lock.d.ts +8 -8
  46. package/dist/operation-lock.d.ts.map +1 -1
  47. package/dist/operation-lock.js +99 -32
  48. package/dist/operation-lock.js.map +1 -1
  49. package/dist/operation-lock.test.js +51 -4
  50. package/dist/operation-lock.test.js.map +1 -1
  51. package/dist/personal-vault.d.ts.map +1 -1
  52. package/dist/personal-vault.js +8 -2
  53. package/dist/personal-vault.js.map +1 -1
  54. package/dist/personal-vault.test.js +34 -0
  55. package/dist/personal-vault.test.js.map +1 -1
  56. package/dist/prefix-coalesce.d.ts +20 -9
  57. package/dist/prefix-coalesce.d.ts.map +1 -1
  58. package/dist/prefix-coalesce.js +124 -28
  59. package/dist/prefix-coalesce.js.map +1 -1
  60. package/dist/prefix-coalesce.test.js +57 -2
  61. package/dist/prefix-coalesce.test.js.map +1 -1
  62. package/dist/remote-pull.d.ts +6 -1
  63. package/dist/remote-pull.d.ts.map +1 -1
  64. package/dist/remote-pull.js +62 -13
  65. package/dist/remote-pull.js.map +1 -1
  66. package/dist/remote-pull.test.js +189 -0
  67. package/dist/remote-pull.test.js.map +1 -1
  68. package/dist/s3.d.ts +2 -0
  69. package/dist/s3.d.ts.map +1 -1
  70. package/dist/s3.js +197 -116
  71. package/dist/s3.js.map +1 -1
  72. package/dist/s3.test.js +109 -0
  73. package/dist/s3.test.js.map +1 -1
  74. package/dist/scope-shrink.d.ts +3 -2
  75. package/dist/scope-shrink.d.ts.map +1 -1
  76. package/dist/scope-shrink.js +1 -1
  77. package/dist/scope-shrink.js.map +1 -1
  78. package/dist/skill-telemetry.d.ts +1 -1
  79. package/dist/skill-telemetry.d.ts.map +1 -1
  80. package/dist/skill-telemetry.js +69 -9
  81. package/dist/skill-telemetry.js.map +1 -1
  82. package/dist/skill-telemetry.test.js +86 -0
  83. package/dist/skill-telemetry.test.js.map +1 -1
  84. package/dist/sync/event-sync.d.ts +6 -0
  85. package/dist/sync/event-sync.d.ts.map +1 -1
  86. package/dist/sync/event-sync.js +34 -1
  87. package/dist/sync/event-sync.js.map +1 -1
  88. package/dist/sync/event-sync.test.js +73 -0
  89. package/dist/sync/event-sync.test.js.map +1 -1
  90. package/dist/sync/metrics.d.ts +17 -1
  91. package/dist/sync/metrics.d.ts.map +1 -1
  92. package/dist/sync/metrics.js +32 -1
  93. package/dist/sync/metrics.js.map +1 -1
  94. package/dist/sync/metrics.test.js +74 -1
  95. package/dist/sync/metrics.test.js.map +1 -1
  96. package/dist/sync/pull-scope.d.ts.map +1 -1
  97. package/dist/sync/pull-scope.js +15 -7
  98. package/dist/sync/pull-scope.js.map +1 -1
  99. package/dist/sync/push-receiver.d.ts +6 -5
  100. package/dist/sync/push-receiver.d.ts.map +1 -1
  101. package/dist/sync/push-receiver.js +13 -15
  102. package/dist/sync/push-receiver.js.map +1 -1
  103. package/dist/sync/push-receiver.test.js +36 -1
  104. package/dist/sync/push-receiver.test.js.map +1 -1
  105. package/dist/telemetry.d.ts +1 -1
  106. package/dist/telemetry.d.ts.map +1 -1
  107. package/dist/telemetry.js +59 -6
  108. package/dist/telemetry.js.map +1 -1
  109. package/dist/telemetry.test.js +74 -0
  110. package/dist/telemetry.test.js.map +1 -1
  111. package/dist/types.d.ts +8 -0
  112. package/dist/types.d.ts.map +1 -1
  113. package/dist/watcher.d.ts +36 -0
  114. package/dist/watcher.d.ts.map +1 -1
  115. package/dist/watcher.js +152 -30
  116. package/dist/watcher.js.map +1 -1
  117. package/dist/watcher.test.js +103 -0
  118. package/dist/watcher.test.js.map +1 -1
  119. package/package.json +1 -1
  120. package/src/bin/sync-runner.test.ts +298 -11
  121. package/src/bin/sync-runner.ts +254 -52
  122. package/src/cli/rescue-classify-ordering.test.ts +61 -0
  123. package/src/cli/rescue-core.ts +174 -15
  124. package/src/cli/share.test.ts +38 -0
  125. package/src/cli/share.ts +103 -34
  126. package/src/cli/sync.test.ts +435 -1
  127. package/src/cli/sync.ts +217 -64
  128. package/src/cognito-auth.test.ts +77 -0
  129. package/src/cognito-auth.ts +73 -11
  130. package/src/index.ts +8 -0
  131. package/src/journal.test.ts +72 -0
  132. package/src/journal.ts +95 -8
  133. package/src/machine-auth.test.ts +64 -2
  134. package/src/object-io.test.ts +142 -0
  135. package/src/object-io.ts +182 -30
  136. package/src/operation-lock.test.ts +63 -4
  137. package/src/operation-lock.ts +99 -31
  138. package/src/personal-vault.test.ts +42 -0
  139. package/src/personal-vault.ts +8 -2
  140. package/src/prefix-coalesce.test.ts +71 -1
  141. package/src/prefix-coalesce.ts +155 -30
  142. package/src/remote-pull.test.ts +205 -0
  143. package/src/remote-pull.ts +77 -14
  144. package/src/s3.test.ts +126 -0
  145. package/src/s3.ts +237 -122
  146. package/src/scope-shrink.ts +6 -3
  147. package/src/skill-telemetry.test.ts +109 -0
  148. package/src/skill-telemetry.ts +82 -14
  149. package/src/sync/event-sync.test.ts +75 -0
  150. package/src/sync/event-sync.ts +54 -1
  151. package/src/sync/metrics.test.ts +81 -0
  152. package/src/sync/metrics.ts +59 -4
  153. package/src/sync/pull-scope.ts +23 -7
  154. package/src/sync/push-receiver.test.ts +38 -1
  155. package/src/sync/push-receiver.ts +15 -18
  156. package/src/telemetry.test.ts +85 -0
  157. package/src/telemetry.ts +69 -6
  158. package/src/types.ts +8 -0
  159. package/src/watcher.test.ts +117 -0
  160. package/src/watcher.ts +209 -33
@@ -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
  }
@@ -157,6 +157,111 @@ describe("decideRemotePulls", () => {
157
157
  expect(result.download).toEqual([]);
158
158
  });
159
159
 
160
+ it("F07: excludes scope-shrink tombstones from remote-delete decisions", () => {
161
+ const journal: SyncJournal = {
162
+ version: "2",
163
+ lastSync: "2026-05-01T00:00:00Z",
164
+ files: {
165
+ "docs/scope-shrunk-dirty.md": {
166
+ hash: "dirty-before-shrink",
167
+ size: 42,
168
+ syncedAt: "2026-05-01T00:00:00Z",
169
+ direction: "down",
170
+ remoteEtag: "old-dirty",
171
+ removedAt: "2026-05-02T00:00:00Z",
172
+ removedReason: "scope_shrink",
173
+ },
174
+ "docs/live-gone.md": {
175
+ hash: "h",
176
+ size: 100,
177
+ syncedAt: "2026-05-01T00:00:00Z",
178
+ direction: "down",
179
+ remoteEtag: "old-live",
180
+ },
181
+ },
182
+ pulls: [],
183
+ };
184
+ const result = decideRemotePulls({
185
+ remoteFiles: [],
186
+ journal,
187
+ conflictKeys: new Set(),
188
+ });
189
+ expect(result.deleteLocal).toEqual(["docs/live-gone.md"]);
190
+ expect(result.download).toEqual([]);
191
+ });
192
+
193
+ it("R-F07: excludes scope-shrink-protected survivors from remote-delete decisions", async () => {
194
+ const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rf07-"));
195
+ try {
196
+ const journal: SyncJournal = {
197
+ version: "2",
198
+ lastSync: "2026-05-01T00:00:00Z",
199
+ files: {
200
+ "companies/indigo/projects/mine.md": {
201
+ hash: "h-mine",
202
+ size: 6,
203
+ syncedAt: "2026-05-01T00:00:00Z",
204
+ direction: "down",
205
+ remoteEtag: "old-mine",
206
+ createdBySub: "owner-sub",
207
+ },
208
+ "companies/indigo/projects/legacy.md": {
209
+ hash: "h-legacy",
210
+ size: 6,
211
+ syncedAt: "2026-05-01T00:00:00Z",
212
+ direction: "down",
213
+ remoteEtag: "old-legacy",
214
+ },
215
+ "companies/indigo/meetings/gone.md": {
216
+ hash: "h-gone",
217
+ size: 4,
218
+ syncedAt: "2026-05-01T00:00:00Z",
219
+ direction: "down",
220
+ remoteEtag: "old-gone",
221
+ createdBySub: "peer-sub",
222
+ },
223
+ },
224
+ pulls: [
225
+ {
226
+ pullId: "01PREV",
227
+ companyUid: "cmp_indigo",
228
+ startedAt: "2026-05-19T00:00:00.000Z",
229
+ completedAt: "2026-05-19T00:00:05.000Z",
230
+ syncMode: "all",
231
+ prefixSet: ["companies/indigo/"],
232
+ scopeChangeDetected: false,
233
+ orphansRemoved: 0,
234
+ orphansBlocked: 0,
235
+ },
236
+ ],
237
+ };
238
+
239
+ const result = await pullCompany({
240
+ ctx: makeCtx(),
241
+ journal,
242
+ hqRoot,
243
+ callerSub: "owner-sub",
244
+ scope: {
245
+ companyUid: "cmp_indigo",
246
+ syncMode: "shared",
247
+ prefixSet: ["companies/indigo/meetings/"],
248
+ strategy: "vend-fanout",
249
+ },
250
+ listFn: async () => [],
251
+ });
252
+
253
+ expect(result.decision.deleteLocal).toEqual([
254
+ "companies/indigo/meetings/gone.md",
255
+ ]);
256
+ expect(result.decision.skip.map((f) => f.key).sort()).toEqual([
257
+ "companies/indigo/projects/legacy.md",
258
+ "companies/indigo/projects/mine.md",
259
+ ]);
260
+ } finally {
261
+ fs.rmSync(hqRoot, { recursive: true, force: true });
262
+ }
263
+ });
264
+
160
265
  it("never deletes locally for entries that have NEVER been synced down", () => {
161
266
  // Push-only entries (direction: 'up') represent files we created locally
162
267
  // and uploaded. If the user later deletes them remotely from another
@@ -521,6 +626,28 @@ describe("listRemoteForScope", () => {
521
626
  ]);
522
627
  });
523
628
 
629
+ it("R-F16: strategy=vend-fanout post-filters exact-grant siblings", async () => {
630
+ const scope = resolveCompanyScope({
631
+ companyUid: "cmp_indigo",
632
+ companyPrefix: "companies/indigo/",
633
+ syncConfig: makeSyncConfig({ syncMode: "shared" }),
634
+ explicitGrants: [makeGrant("companies/indigo/README.md")],
635
+ });
636
+ const files = await listRemoteForScope({
637
+ ctx: makeCtx(),
638
+ scope,
639
+ listFn: async (_ctx, prefix) => {
640
+ expect(prefix).toBe("companies/indigo/README.md");
641
+ return [
642
+ remote({ key: "companies/indigo/README.md" }),
643
+ remote({ key: "companies/indigo/README.md.bak" }),
644
+ ];
645
+ },
646
+ });
647
+
648
+ expect(files.map((f) => f.key)).toEqual(["companies/indigo/README.md"]);
649
+ });
650
+
524
651
  it("strategy=vend-fanout short-circuits to [] on empty prefixSet", async () => {
525
652
  let listed = false;
526
653
  const files = await listRemoteForScope({
@@ -859,6 +986,84 @@ describe("pullCompany (engine orchestrator)", () => {
859
986
  expect(fs.existsSync(legacyAbs)).toBe(true); // legacy file retained
860
987
  });
861
988
 
989
+ it("RF-F07DUR: scope-shrink-protected survivors are excluded on a second pull", async () => {
990
+ const journal: SyncJournal = {
991
+ version: "2",
992
+ lastSync: "",
993
+ files: {
994
+ "companies/indigo/projects/legacy.md": {
995
+ hash: "h-legacy",
996
+ size: 6,
997
+ syncedAt: "2026-05-01T00:00:00.000Z",
998
+ direction: "down",
999
+ remoteEtag: "old-legacy",
1000
+ },
1001
+ "companies/indigo/meetings/gone.md": {
1002
+ hash: "h-gone",
1003
+ size: 4,
1004
+ syncedAt: "2026-05-01T00:00:00.000Z",
1005
+ direction: "down",
1006
+ remoteEtag: "old-gone",
1007
+ createdBySub: "peer-sub",
1008
+ },
1009
+ },
1010
+ pulls: [
1011
+ {
1012
+ pullId: "01PREV",
1013
+ companyUid: "cmp_indigo",
1014
+ startedAt: "2026-05-19T00:00:00.000Z",
1015
+ completedAt: "2026-05-19T00:00:05.000Z",
1016
+ syncMode: "all",
1017
+ prefixSet: ["companies/indigo/"],
1018
+ scopeChangeDetected: false,
1019
+ orphansRemoved: 0,
1020
+ orphansBlocked: 0,
1021
+ },
1022
+ ],
1023
+ };
1024
+ const narrowedScope = {
1025
+ companyUid: "cmp_indigo",
1026
+ syncMode: "shared" as const,
1027
+ prefixSet: ["companies/indigo/meetings/"],
1028
+ strategy: "vend-fanout" as const,
1029
+ };
1030
+
1031
+ const first = await pullCompany({
1032
+ ctx: makeCtx(),
1033
+ journal,
1034
+ hqRoot,
1035
+ callerSub: "owner-sub",
1036
+ scope: narrowedScope,
1037
+ listFn: async () => [],
1038
+ });
1039
+
1040
+ expect(first.decision.skip.map((f) => f.key)).toContain(
1041
+ "companies/indigo/projects/legacy.md",
1042
+ );
1043
+ expect(first.decision.deleteLocal).toEqual([
1044
+ "companies/indigo/meetings/gone.md",
1045
+ ]);
1046
+ expect(
1047
+ journal.files["companies/indigo/projects/legacy.md"]?.outOfScopeProtected,
1048
+ ).toBe(true);
1049
+
1050
+ const second = await pullCompany({
1051
+ ctx: makeCtx(),
1052
+ journal,
1053
+ hqRoot,
1054
+ callerSub: "owner-sub",
1055
+ scope: narrowedScope,
1056
+ listFn: async () => [],
1057
+ });
1058
+
1059
+ expect(second.decision.skip.map((f) => f.key)).toContain(
1060
+ "companies/indigo/projects/legacy.md",
1061
+ );
1062
+ expect(second.decision.deleteLocal).toEqual([
1063
+ "companies/indigo/meetings/gone.md",
1064
+ ]);
1065
+ });
1066
+
862
1067
  it("GC's expired tombstones at the start of every leg", async () => {
863
1068
  const old = new Date(
864
1069
  Date.now() - 31 * 24 * 60 * 60 * 1000,