@indigoai-us/hq-cloud 5.41.0 → 5.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Unit tests for scope-aware download (US-005 wiring into `sync()`).
3
+ *
4
+ * Covers the contract added when `syncMode` / `prefixSet` were threaded
5
+ * through `computePullPlan` + the scope-shrink pass:
6
+ *
7
+ * - `all` → no filtering (regression guard lives in sync.test.ts).
8
+ * - `shared` → only keys covered by `prefixSet` download; the rest are
9
+ * classified `skip-out-of-scope` (NOT downloaded).
10
+ * - `custom` → same mechanism, driven by the explicit path list.
11
+ * - Idempotency → a second `shared` pull downloads nothing and removes
12
+ * nothing (the PullRecord makes scope-change a no-op).
13
+ * - Scope shrink → narrowing `all → shared` prunes the now-out-of-scope
14
+ * CLEAN local orphan; a DIRTY orphan aborts with
15
+ * `ScopeShrinkBlockedError` unless `forceScopeShrink`.
16
+ *
17
+ * The security contract (this filter is footprint-only, never an authz
18
+ * boundary) is asserted indirectly: out-of-scope keys are still LISTED and
19
+ * accessible — the engine simply chooses not to materialize them.
20
+ */
21
+
22
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
23
+ import * as fs from "fs";
24
+ import * as path from "path";
25
+ import * as os from "os";
26
+ import { clearContextCache } from "../context.js";
27
+ import type { VaultServiceConfig } from "../types.js";
28
+
29
+ // Mutable remote-file list so each test controls what the vault returns.
30
+ const REMOTE: { current: Array<{ key: string; size: number; lastModified: Date; etag: string }> } = {
31
+ current: [
32
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
33
+ { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
34
+ ],
35
+ };
36
+
37
+ vi.mock("../s3.js", async () => {
38
+ const innerFs = await import("fs");
39
+ const innerPath = await import("path");
40
+ const { vi: innerVi } = await import("vitest");
41
+ return {
42
+ uploadFile: innerVi.fn().mockResolvedValue(undefined),
43
+ downloadFile: innerVi
44
+ .fn()
45
+ .mockImplementation(async (_ctx: unknown, key: string, localPath: string) => {
46
+ const dir = innerPath.dirname(localPath);
47
+ if (!innerFs.existsSync(dir)) innerFs.mkdirSync(dir, { recursive: true });
48
+ // Deterministic per-key body so re-downloads produce a stable hash.
49
+ innerFs.writeFileSync(localPath, `mock:${key}`);
50
+ return { metadata: {} };
51
+ }),
52
+ listRemoteFiles: innerVi.fn().mockImplementation(async () => REMOTE.current),
53
+ deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
54
+ // HEAD returns metadata (object exists) for any key still in REMOTE,
55
+ // null otherwise — mirrors the real bucket so the tombstone HEAD-verify
56
+ // pass behaves correctly for out-of-scope (still-present) keys.
57
+ headRemoteFile: innerVi.fn().mockImplementation(async (_ctx: unknown, key: string) => {
58
+ const hit = REMOTE.current.find((r) => r.key === key);
59
+ return hit ? { metadata: {}, size: hit.size, etag: hit.etag } : null;
60
+ }),
61
+ };
62
+ });
63
+
64
+ import { sync } from "./sync.js";
65
+ import {
66
+ ScopeShrinkBlockedError,
67
+ ScopeShrinkLargePruneError,
68
+ } from "../scope-shrink.js";
69
+
70
+ const mockConfig: VaultServiceConfig = {
71
+ apiUrl: "https://vault-api.test",
72
+ authToken: "test-jwt-token",
73
+ region: "us-east-1",
74
+ };
75
+
76
+ const mockEntity = {
77
+ uid: "cmp_01ABCDEF",
78
+ slug: "acme",
79
+ bucketName: "hq-vault-acme-123",
80
+ status: "active",
81
+ };
82
+
83
+ const mockVendResponse = {
84
+ credentials: {
85
+ accessKeyId: "ASIA_TEST_KEY",
86
+ secretAccessKey: "test-secret",
87
+ sessionToken: "test-session-token",
88
+ expiration: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
89
+ },
90
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
91
+ };
92
+
93
+ function setupFetchMock() {
94
+ const fetchMock = vi.fn().mockImplementation(async (url: string) => {
95
+ const urlStr = String(url);
96
+ if (urlStr.includes("/entity/check-slug/me")) {
97
+ return {
98
+ ok: true,
99
+ status: 200,
100
+ json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
101
+ text: async () => "",
102
+ };
103
+ }
104
+ if (urlStr.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(urlStr)) {
105
+ return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
106
+ }
107
+ if (urlStr.includes("/sts/vend")) {
108
+ return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
109
+ }
110
+ return { ok: false, status: 404, text: async () => "Not found" };
111
+ });
112
+ vi.stubGlobal("fetch", fetchMock);
113
+ return fetchMock;
114
+ }
115
+
116
+ describe("sync — scope-aware download (US-005)", () => {
117
+ let tmpDir: string;
118
+ let stateDir: string;
119
+ const companyRel = (p: string) => path.join(tmpDir, "companies", "acme", p);
120
+
121
+ beforeEach(() => {
122
+ clearContextCache();
123
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-scope-"));
124
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-scope-"));
125
+ process.env.HQ_STATE_DIR = stateDir;
126
+ // Reset the remote list every test (a prior test may have mutated it).
127
+ REMOTE.current = [
128
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
129
+ { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
130
+ ];
131
+ setupFetchMock();
132
+ });
133
+
134
+ afterEach(() => {
135
+ vi.unstubAllGlobals();
136
+ vi.clearAllMocks();
137
+ fs.rmSync(tmpDir, { recursive: true, force: true });
138
+ fs.rmSync(stateDir, { recursive: true, force: true });
139
+ delete process.env.HQ_STATE_DIR;
140
+ });
141
+
142
+ it("shared mode downloads only keys covered by prefixSet; rest are skip-out-of-scope", async () => {
143
+ const result = await sync({
144
+ company: "acme",
145
+ vaultConfig: mockConfig,
146
+ hqRoot: tmpDir,
147
+ syncMode: "shared",
148
+ prefixSet: ["knowledge/"],
149
+ });
150
+
151
+ expect(result.filesDownloaded).toBe(1);
152
+ expect(result.filesOutOfScope).toBe(1);
153
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
154
+ // docs/handoff.md is out of scope — never materialized.
155
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
156
+ });
157
+
158
+ it("custom mode behaves like shared, driven by the explicit prefix list", async () => {
159
+ const result = await sync({
160
+ company: "acme",
161
+ vaultConfig: mockConfig,
162
+ hqRoot: tmpDir,
163
+ syncMode: "custom",
164
+ prefixSet: ["docs/"],
165
+ });
166
+
167
+ expect(result.filesDownloaded).toBe(1);
168
+ expect(result.filesOutOfScope).toBe(1);
169
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
170
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
171
+ });
172
+
173
+ it("shared mode with empty prefixSet downloads nothing", async () => {
174
+ const result = await sync({
175
+ company: "acme",
176
+ vaultConfig: mockConfig,
177
+ hqRoot: tmpDir,
178
+ syncMode: "shared",
179
+ prefixSet: [],
180
+ });
181
+
182
+ expect(result.filesDownloaded).toBe(0);
183
+ expect(result.filesOutOfScope).toBe(2);
184
+ });
185
+
186
+ it("is idempotent: a second shared pull downloads and removes nothing", async () => {
187
+ const opts = {
188
+ company: "acme",
189
+ vaultConfig: mockConfig,
190
+ hqRoot: tmpDir,
191
+ syncMode: "shared" as const,
192
+ prefixSet: ["knowledge/"],
193
+ };
194
+ const first = await sync(opts);
195
+ expect(first.filesDownloaded).toBe(1);
196
+
197
+ const second = await sync(opts);
198
+ expect(second.filesDownloaded).toBe(0);
199
+ expect(second.scopeOrphansRemoved).toBe(0);
200
+ expect(second.scopeOrphansBlocked).toBe(0);
201
+ // knowledge file still present; nothing churned.
202
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
203
+ });
204
+
205
+ it("scope shrink (all → shared) prunes the clean out-of-scope orphan", async () => {
206
+ // First pull EVERYTHING under all-mode.
207
+ const all = await sync({
208
+ company: "acme",
209
+ vaultConfig: mockConfig,
210
+ hqRoot: tmpDir,
211
+ syncMode: "all",
212
+ });
213
+ expect(all.filesDownloaded).toBe(2);
214
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
215
+
216
+ // Narrow to shared/knowledge → docs/handoff.md is now a clean orphan.
217
+ const shared = await sync({
218
+ company: "acme",
219
+ vaultConfig: mockConfig,
220
+ hqRoot: tmpDir,
221
+ syncMode: "shared",
222
+ prefixSet: ["knowledge/"],
223
+ });
224
+ expect(shared.scopeOrphansRemoved).toBe(1);
225
+ expect(shared.scopeOrphansBlocked).toBe(0);
226
+ // The clean orphan was pruned; the in-scope file stays.
227
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
228
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
229
+ });
230
+
231
+ it("refuses a bulk auto-prune over the safety cap, then proceeds when forced", async () => {
232
+ // Pull both files under all-mode, then narrow to a scope covering neither
233
+ // → 2 clean orphans. With the cap set to 1, the auto-prune is refused.
234
+ await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
235
+
236
+ // Narrow to a scope covering neither file → 2 clean orphans; cap at 1.
237
+ process.env.HQ_SYNC_MAX_AUTO_PRUNE = "1";
238
+ try {
239
+ await expect(
240
+ sync({
241
+ company: "acme",
242
+ vaultConfig: mockConfig,
243
+ hqRoot: tmpDir,
244
+ syncMode: "shared",
245
+ prefixSet: ["nonexistent/"],
246
+ }),
247
+ ).rejects.toBeInstanceOf(ScopeShrinkLargePruneError);
248
+ // Nothing deleted on the refused run.
249
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
250
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
251
+
252
+ // Forced: the prune proceeds despite the cap.
253
+ const forced = await sync({
254
+ company: "acme",
255
+ vaultConfig: mockConfig,
256
+ hqRoot: tmpDir,
257
+ syncMode: "shared",
258
+ prefixSet: ["nonexistent/"],
259
+ forceScopeShrink: true,
260
+ });
261
+ expect(forced.scopeOrphansRemoved).toBe(2);
262
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
263
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
264
+ } finally {
265
+ delete process.env.HQ_SYNC_MAX_AUTO_PRUNE;
266
+ }
267
+ });
268
+
269
+ it("scope shrink aborts on a DIRTY orphan unless forceScopeShrink is set", async () => {
270
+ await sync({
271
+ company: "acme",
272
+ vaultConfig: mockConfig,
273
+ hqRoot: tmpDir,
274
+ syncMode: "all",
275
+ });
276
+ // Locally modify the soon-to-be-orphan so it's dirty (hash mismatch).
277
+ fs.writeFileSync(companyRel("docs/handoff.md"), "LOCAL EDIT — do not delete");
278
+
279
+ // Default: abort with the structured error; the dirty file is untouched.
280
+ await expect(
281
+ sync({
282
+ company: "acme",
283
+ vaultConfig: mockConfig,
284
+ hqRoot: tmpDir,
285
+ syncMode: "shared",
286
+ prefixSet: ["knowledge/"],
287
+ }),
288
+ ).rejects.toBeInstanceOf(ScopeShrinkBlockedError);
289
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
290
+ expect(fs.readFileSync(companyRel("docs/handoff.md"), "utf-8")).toBe(
291
+ "LOCAL EDIT — do not delete",
292
+ );
293
+
294
+ // With force: the leg proceeds, the dirty file is LEFT ON DISK, only its
295
+ // journal entry is tombstoned.
296
+ const forced = await sync({
297
+ company: "acme",
298
+ vaultConfig: mockConfig,
299
+ hqRoot: tmpDir,
300
+ syncMode: "shared",
301
+ prefixSet: ["knowledge/"],
302
+ forceScopeShrink: true,
303
+ });
304
+ expect(forced.scopeOrphansBlocked).toBe(1);
305
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
306
+ });
307
+ });
package/src/cli/sync.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import * as fs from "fs";
9
9
  import * as path from "path";
10
10
  import type { VaultServiceConfig, SyncJournal } from "../types.js";
11
+ import type { SyncMode } from "../vault-client.js";
11
12
  import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
12
13
  import { downloadFile, listRemoteFiles, headRemoteFile } from "../s3.js";
13
14
  import type { RemoteFile } from "../s3.js";
@@ -20,7 +21,19 @@ import {
20
21
  removeEntry,
21
22
  getEntry,
22
23
  normalizeEtag,
24
+ migrateToV2,
25
+ gcTombstones,
26
+ lastPullRecord,
27
+ appendPullRecord,
28
+ generatePullId,
23
29
  } from "../journal.js";
30
+ import {
31
+ buildScopeShrinkPlan,
32
+ applyScopeShrink,
33
+ ScopeShrinkBlockedError,
34
+ ScopeShrinkLargePruneError,
35
+ } from "../scope-shrink.js";
36
+ import { coalescePrefixes, isCoveredByAny } from "../prefix-coalesce.js";
24
37
  import { createIgnoreFilter } from "../ignore.js";
25
38
  import { isEphemeralPath } from "./share.js";
26
39
  import { resolveConflict } from "./conflict.js";
@@ -255,6 +268,40 @@ export interface SyncOptions {
255
268
  * TS runner and Rust first-push share idempotency state.
256
269
  */
257
270
  journalSlug?: string;
271
+ /**
272
+ * Effective sync mode for this leg (US-005 wiring). Defaults to `"all"`
273
+ * when absent, preserving the legacy full-bucket pull. The runner resolves
274
+ * this from the membership's sync-config (`getMembershipSyncConfig`).
275
+ *
276
+ * SECURITY NOTE: this is a footprint/UX filter, NOT an authorization
277
+ * boundary. The security boundary is the server (STS credential scope +
278
+ * ACL). An owner's STS is wide (role-bypass), so this client-side scope is
279
+ * what makes selective download durable for owners — but it never grants
280
+ * access beyond what STS already permits.
281
+ */
282
+ syncMode?: SyncMode;
283
+ /**
284
+ * Coalesced, COMPANY-RELATIVE prefixes the current pull is scoped to when
285
+ * `syncMode` is `"shared"` or `"custom"` (same namespace as `RemoteFile.key`
286
+ * and the per-slug journal keys — e.g. `"knowledge/"`, `"projects/x/"`).
287
+ * Ignored when `syncMode` is `"all"`. The runner derives this from the
288
+ * caller's explicit grants (`shared`) or `customPaths` (`custom`) and is
289
+ * responsible for normalizing into the company-relative namespace.
290
+ *
291
+ * A `shared` leg with an empty/undefined `prefixSet` means "nothing is
292
+ * shared with me" → download nothing. The runner MUST fall back to `"all"`
293
+ * (not empty `"shared"`) on any grant-resolution error, so a transient
294
+ * failure can never silently prune the local tree.
295
+ */
296
+ prefixSet?: string[];
297
+ /**
298
+ * When the effective scope shrinks relative to the last pull and the shrink
299
+ * would orphan locally-modified ("dirty") files, `sync()` aborts with a
300
+ * `ScopeShrinkBlockedError` by default. Set `true` to proceed anyway:
301
+ * dirty files are LEFT ON DISK and only their journal entries are
302
+ * tombstoned. Mirrors `hq sync narrow --force`.
303
+ */
304
+ forceScopeShrink?: boolean;
258
305
  }
259
306
 
260
307
  export interface SyncResult {
@@ -297,6 +344,42 @@ export interface SyncResult {
297
344
  * disappeared from the remote.
298
345
  */
299
346
  filesTombstoned: number;
347
+ /**
348
+ * Count of remote keys NOT downloaded this run because they fall outside
349
+ * the effective `syncMode` scope (US-005). Always 0 in `all` mode. Distinct
350
+ * from `filesSkipped` (which measures "unchanged on this run") so consumers
351
+ * can render a "N outside your sync scope" line. The matching local cleanup
352
+ * of previously-downloaded-now-out-of-scope files is reported via
353
+ * `scopeOrphansRemoved`.
354
+ */
355
+ filesOutOfScope: number;
356
+ /**
357
+ * Clean local orphans deleted this run because a scope shrink moved them
358
+ * outside the effective scope (US-005). 0 when scope did not shrink.
359
+ */
360
+ scopeOrphansRemoved: number;
361
+ /**
362
+ * Dirty (locally-modified) orphans that a scope shrink would have pruned.
363
+ * When `forceScopeShrink` is false these are surfaced via a thrown
364
+ * `ScopeShrinkBlockedError` and the leg never reaches this result; when
365
+ * true they are left on disk and tombstoned, and counted here.
366
+ */
367
+ scopeOrphansBlocked: number;
368
+ }
369
+
370
+ /**
371
+ * Resolve the auto-prune safety cap (US-005 bulk-delete guard). An automatic
372
+ * scope shrink that would delete more than this many CLEAN local files in one
373
+ * pull is refused with `ScopeShrinkLargePruneError`. Default 100; `0` (or a
374
+ * non-positive / unparseable value) disables the cap (unlimited). Override via
375
+ * `HQ_SYNC_MAX_AUTO_PRUNE`.
376
+ */
377
+ export function resolveAutoPruneCap(): number {
378
+ const raw = process.env.HQ_SYNC_MAX_AUTO_PRUNE;
379
+ if (raw === undefined || raw === "") return 100;
380
+ const parsed = Number.parseInt(raw, 10);
381
+ // NaN or negative → treat as "unlimited" (0) rather than silently capping.
382
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
300
383
  }
301
384
 
302
385
  /**
@@ -330,13 +413,32 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
330
413
  : path.join(hqRoot, "companies", ctx.slug);
331
414
  const shouldSync = createIgnoreFilter(hqRoot);
332
415
  const journalSlug = options.journalSlug ?? ctx.slug;
333
- const journal = readJournal(journalSlug);
416
+ const startedAt = new Date().toISOString();
417
+ // Migrate v1 → v2 in place so the scope-shrink / pull-record machinery has
418
+ // its fields, and GC any tombstones past the 30-day retention window before
419
+ // we re-evaluate orphans (so a long-pruned path can re-download cleanly).
420
+ const journal = migrateToV2(readJournal(journalSlug));
421
+ gcTombstones(journal, Date.now());
422
+
423
+ // ── Effective download scope (US-005) ─────────────────────────────────────
424
+ // `all` → prefixSet `[""]`, which `isCoveredByAny` treats as "covers
425
+ // everything" — so the download filter and the scope-shrink
426
+ // comparison both become no-ops, preserving legacy full-bucket
427
+ // behavior bit-for-bit.
428
+ // `shared`/`custom` → the coalesced, company-relative prefix set the runner
429
+ // resolved. An empty set means "nothing in scope" → download
430
+ // nothing (the runner falls back to `all` on resolution errors, so
431
+ // empty here is an intentional "nothing shared", never a failure).
432
+ const syncMode: SyncMode = options.syncMode ?? "all";
433
+ const currentPrefixSet =
434
+ syncMode === "all" ? [""] : coalescePrefixes(options.prefixSet ?? []);
334
435
 
335
436
  let filesDownloaded = 0;
336
437
  let bytesDownloaded = 0;
337
438
  let filesSkipped = 0;
338
439
  let conflicts = 0;
339
440
  let filesTombstoned = 0;
441
+ let filesOutOfScope = 0;
340
442
  const conflictPaths: string[] = [];
341
443
 
342
444
  // List all remote files (IAM session policy filters at the AWS layer)
@@ -353,6 +455,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
353
455
  options.personalMode === true,
354
456
  options.includeLocalCompanies === true,
355
457
  options.teamSyncedSlugs ?? null,
458
+ currentPrefixSet,
356
459
  );
357
460
 
358
461
  emit({
@@ -367,6 +470,83 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
367
470
  filesToDelete: 0,
368
471
  });
369
472
 
473
+ // ── Scope-shrink cleanup (US-005) ─────────────────────────────────────────
474
+ // If the effective scope narrowed since the last pull, files that were
475
+ // pulled under the old scope but fall outside the new one are orphans. We
476
+ // delete only CLEAN orphans (provably unchanged since last sync); dirty
477
+ // (locally-modified) orphans are sacred. By default a dirty orphan aborts
478
+ // the leg with a structured error the CLI renders; `forceScopeShrink` keeps
479
+ // dirty files on disk and only tombstones their journal entries.
480
+ //
481
+ // `companyRoot` is passed as the module's `hqRoot` so its `path.join(root,
482
+ // key)` resolves company-relative journal keys correctly (the scope-shrink
483
+ // module is namespace-agnostic — root + keys + prefixSet must simply agree).
484
+ //
485
+ // Note: this is the durable selective-download fix for OWNERS. An owner's
486
+ // STS is wide (role-bypass), so the remote LIST returns everything and the
487
+ // AWS layer never narrows the pull. This client-side shrink is what makes
488
+ // `hq sync mode shared` actually stick across re-syncs for an owner.
489
+ const lastRecord = lastPullRecord(journal, ctx.uid);
490
+ // A missing record, or a v1-migrated record with an empty prefixSet, means
491
+ // "no recorded scope" → treat the last scope as full-bucket `all` (`[""]`),
492
+ // per the PullRecord.prefixSet contract in types.ts.
493
+ const lastPrefixSet =
494
+ lastRecord && lastRecord.prefixSet.length > 0
495
+ ? lastRecord.prefixSet
496
+ : [""];
497
+ const shrinkPlan = buildScopeShrinkPlan({
498
+ journal,
499
+ hqRoot: companyRoot,
500
+ lastPrefixSet,
501
+ currentPrefixSet,
502
+ });
503
+ if (shrinkPlan.dirty.length > 0 && options.forceScopeShrink !== true) {
504
+ throw new ScopeShrinkBlockedError(
505
+ ctx.uid,
506
+ lastRecord?.syncMode ?? "unknown",
507
+ syncMode,
508
+ shrinkPlan.dirty,
509
+ shrinkPlan.clean,
510
+ );
511
+ }
512
+ // Bulk-delete guard: refuse to auto-prune more than the safety cap of CLEAN
513
+ // files in a single background sync. A deliberate large narrow goes through
514
+ // `hq sync narrow --apply` (its own confirmation), and `--force-scope-shrink`
515
+ // (or raising HQ_SYNC_MAX_AUTO_PRUNE) overrides. Cap of 0 = unlimited (opt
516
+ // out). The engine deletes nothing when it throws here.
517
+ const autoPruneCap = resolveAutoPruneCap();
518
+ if (
519
+ options.forceScopeShrink !== true &&
520
+ autoPruneCap > 0 &&
521
+ shrinkPlan.clean.length > autoPruneCap
522
+ ) {
523
+ throw new ScopeShrinkLargePruneError(
524
+ ctx.uid,
525
+ syncMode,
526
+ shrinkPlan.clean.length,
527
+ autoPruneCap,
528
+ );
529
+ }
530
+ const shrinkResult = applyScopeShrink({
531
+ journal,
532
+ plan: shrinkPlan,
533
+ hqRoot: companyRoot,
534
+ forceScopeShrink: options.forceScopeShrink === true,
535
+ reason: "scope_shrink",
536
+ });
537
+ // Surface each removed clean orphan as a `deleted` progress event so the
538
+ // menubar stream renders the prune the same way it renders a cross-machine
539
+ // tombstone (the Rust parser already handles `deleted: true`).
540
+ for (const orphan of shrinkPlan.clean) {
541
+ emit({
542
+ type: "progress",
543
+ path: orphan.path,
544
+ bytes: 0,
545
+ deleted: true,
546
+ message: "scope-narrowed (removed local copy outside sync scope)",
547
+ });
548
+ }
549
+
370
550
  // Stage 2: execute the plan. Per-item branching mirrors the pre-refactor
371
551
  // inline loop; the only structural change is that classification has
372
552
  // already happened (so `localHash` is reused instead of re-hashing).
@@ -419,6 +599,13 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
419
599
  // run", not a catch-all for everything we didn't download.
420
600
  continue;
421
601
  }
602
+ if (item.action === "skip-out-of-scope") {
603
+ // Outside the effective `syncMode` scope (US-005). Counted on its own
604
+ // axis so `filesSkipped` keeps meaning "unchanged on this run" — these
605
+ // are "deliberately not downloaded because of your sync scope".
606
+ filesOutOfScope++;
607
+ continue;
608
+ }
422
609
 
423
610
  if (item.action === "download") {
424
611
  downloadItems.push(item);
@@ -515,6 +702,12 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
515
702
  // 0 so the field shape stays stable for consumers that
516
703
  // destructure it.
517
704
  filesTombstoned: 0,
705
+ // Scope-shrink ran before execution, so its counts are real even on
706
+ // a conflict abort. `filesOutOfScope` reflects how far the serial
707
+ // pass got before the abort; that's acceptable for an abort result.
708
+ filesOutOfScope,
709
+ scopeOrphansRemoved: shrinkResult.cleanRemoved,
710
+ scopeOrphansBlocked: shrinkResult.dirtyTombstoned,
518
711
  };
519
712
  break;
520
713
  }
@@ -841,6 +1034,22 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
841
1034
  });
842
1035
  }
843
1036
 
1037
+ // Record this pull's boundary (US-005) so the NEXT pull can diff its scope
1038
+ // against ours and detect a shrink. Append before the journal write so it
1039
+ // persists. `prefixSet` is stored in the same company-relative namespace as
1040
+ // the journal keys; `all` mode records `[""]` (covers everything).
1041
+ appendPullRecord(journal, {
1042
+ pullId: generatePullId(),
1043
+ companyUid: ctx.uid,
1044
+ startedAt,
1045
+ completedAt: new Date().toISOString(),
1046
+ syncMode,
1047
+ prefixSet: currentPrefixSet,
1048
+ scopeChangeDetected: shrinkPlan.scopeChangeDetected,
1049
+ orphansRemoved: shrinkResult.cleanRemoved,
1050
+ orphansBlocked: shrinkResult.dirtyTombstoned,
1051
+ });
1052
+
844
1053
  // Stamp lastSync on every successful run so the menubar's "Last sync · X ago"
845
1054
  // ticks even when nothing transferred. updateEntry only fires on actual
846
1055
  // downloads; without this, a no-op sync leaves lastSync at the time of the
@@ -859,6 +1068,9 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
859
1068
  newFilesCount: plan.newFilesCount,
860
1069
  filesExcludedByPolicy: plan.filesExcludedByPolicy,
861
1070
  filesTombstoned,
1071
+ filesOutOfScope,
1072
+ scopeOrphansRemoved: shrinkResult.cleanRemoved,
1073
+ scopeOrphansBlocked: shrinkResult.dirtyTombstoned,
862
1074
  };
863
1075
  }
864
1076
 
@@ -911,6 +1123,10 @@ type PullPlanItem =
911
1123
  // refused to upload these since 5.33.0; the pull walker now refuses to
912
1124
  // download them so legacy litter in cloud staging drains naturally.
913
1125
  | { action: "skip-excluded-policy"; remoteFile: RemoteFile; localPath: string }
1126
+ // Remote keys outside the effective `syncMode` scope (US-005). Present in
1127
+ // the remote LIST (and accessible per STS) but deliberately not downloaded
1128
+ // because the membership's sync scope doesn't cover them.
1129
+ | { action: "skip-out-of-scope"; remoteFile: RemoteFile; localPath: string }
914
1130
  | {
915
1131
  action: "conflict";
916
1132
  remoteFile: RemoteFile;
@@ -945,6 +1161,8 @@ interface PullPlan {
945
1161
  newFilesCount: number;
946
1162
  /** Count of remote keys refused by ephemeral-mirror policy. */
947
1163
  filesExcludedByPolicy: number;
1164
+ /** Count of remote keys skipped because they fall outside the sync scope. */
1165
+ filesOutOfScope: number;
948
1166
  /**
949
1167
  * Journal-known keys missing from the remote LIST. The executor will
950
1168
  * apply each as a local delete (file or symlink) + journal removal,
@@ -973,6 +1191,10 @@ function computePullPlan(
973
1191
  personalMode: boolean,
974
1192
  includeLocalCompanies: boolean,
975
1193
  teamSyncedSlugs: ReadonlySet<string> | null,
1194
+ // Coalesced, company-relative prefixes the pull is scoped to (US-005).
1195
+ // `[""]` (the `all`-mode value) covers everything via `isCoveredByAny`, so
1196
+ // the scope filter below becomes a no-op and legacy behavior is preserved.
1197
+ prefixSet: string[],
976
1198
  ): PullPlan {
977
1199
  const items: PullPlanItem[] = [];
978
1200
 
@@ -1017,6 +1239,17 @@ function computePullPlan(
1017
1239
  }
1018
1240
  }
1019
1241
 
1242
+ // Scope filter (US-005). Keys outside the effective `syncMode` prefix set
1243
+ // are not downloaded. `prefixSet` is `[""]` in `all` mode, which
1244
+ // `isCoveredByAny` treats as covering everything — so this is a no-op for
1245
+ // `all` and preserves the legacy full-bucket pull bit-for-bit. The
1246
+ // previously-downloaded counterparts of these keys (if scope just shrank)
1247
+ // are pruned separately by the scope-shrink pass in `sync()`.
1248
+ if (!isCoveredByAny(remoteFile.key, prefixSet)) {
1249
+ items.push({ action: "skip-out-of-scope", remoteFile, localPath });
1250
+ continue;
1251
+ }
1252
+
1020
1253
  // LIST gives us no kind signal for the remote object — we don't
1021
1254
  // know whether this key is a regular file or a symlink record
1022
1255
  // until we either HEAD it (expensive — N extra calls per pull) or
@@ -1175,6 +1408,7 @@ function computePullPlan(
1175
1408
  let filesToSkip = 0;
1176
1409
  let filesToConflict = 0;
1177
1410
  let filesExcludedByPolicy = 0;
1411
+ let filesOutOfScope = 0;
1178
1412
  const newFiles: Array<{ path: string; bytes: number }> = [];
1179
1413
  for (const item of items) {
1180
1414
  if (item.action === "download") {
@@ -1191,6 +1425,10 @@ function computePullPlan(
1191
1425
  // distinct class surfaced via filesExcludedByPolicy so consumers
1192
1426
  // can render a "N refused by policy" line independently of the
1193
1427
  // generic "N unchanged" tally.
1428
+ } else if (item.action === "skip-out-of-scope") {
1429
+ // Out-of-scope items get their own axis too, mirroring excluded-policy:
1430
+ // they're "deliberately not downloaded (sync scope)", not "unchanged".
1431
+ filesOutOfScope++;
1194
1432
  } else {
1195
1433
  filesToSkip++;
1196
1434
  }
@@ -1294,6 +1532,7 @@ function computePullPlan(
1294
1532
  newFiles,
1295
1533
  newFilesCount: newFiles.length,
1296
1534
  filesExcludedByPolicy,
1535
+ filesOutOfScope,
1297
1536
  tombstones,
1298
1537
  };
1299
1538
  }
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ export {
46
46
  export {
47
47
  coalescePrefixes,
48
48
  isCoveredByAny,
49
+ grantPathToPrefix,
49
50
  } from "./prefix-coalesce.js";
50
51
 
51
52
  // Scope-shrink detection + application (US-005)