@indigoai-us/hq-cloud 6.11.20 → 6.12.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.
@@ -2290,16 +2290,16 @@ describe("personal slot fanout", () => {
2290
2290
  }
2291
2291
  });
2292
2292
 
2293
- it("G: personal slot includes companies/{slug}/ subdirs when cloud:false marker is set AND HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1, excluding _template + team-synced slugs + missing/cloud:true markers", async () => {
2294
- // Gate the new behavior: without this env var the runner falls back to
2295
- // the legacy "companies/ never enumerated" personal vault. Test H below
2296
- // pins the OFF case.
2297
- vi.stubEnv("HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL", "1");
2293
+ it("G: personal slot includes local companies/{slug}/ subdirs by default, excluding _template + team-synced slugs + cloud:true markers", async () => {
2294
+ // Local (non-cloud) companies sync to the personal vault unconditionally
2295
+ // no env-var gate. A missing/markerless company.yaml is INCLUDED; only
2296
+ // _template, team-synced slugs, and an explicit cloud:true marker are
2297
+ // excluded.
2298
2298
  const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
2299
2299
  const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
2300
2300
  try {
2301
2301
  // Top-level personal-vault content — anchors the assertion that the
2302
- // existing top-level walk still runs alongside the new company-subdir
2302
+ // existing top-level walk still runs alongside the company-subdir
2303
2303
  // discovery.
2304
2304
  fs.mkdirSync(path.join(tmpHqRoot, "knowledge"));
2305
2305
 
@@ -2312,10 +2312,10 @@ describe("personal slot fanout", () => {
2312
2312
  }
2313
2313
  };
2314
2314
  mkCompany("free-co", "cloud: false\nname: Free Co\n"); // INCLUDED
2315
+ mkCompany("zilch", null); // INCLUDED — no company.yaml (default-on)
2315
2316
  mkCompany("acme", "cloud: false\n"); // EXCLUDED — team-synced (membership below)
2316
- mkCompany("_template", "cloud: false\n"); // EXCLUDED — hard-listed slug
2317
- mkCompany("cloud-team", "cloud: true\n"); // EXCLUDED — wrong marker
2318
- mkCompany("zilch", null); // EXCLUDED — no company.yaml at all
2317
+ mkCompany("_template", null); // EXCLUDED — hard-listed slug
2318
+ mkCompany("cloud-team", "cloud: true\n"); // EXCLUDED — cloud-backed marker
2319
2319
 
2320
2320
  const deps = makeDeps({
2321
2321
  createVaultClient: () =>
@@ -2347,17 +2347,17 @@ describe("personal slot fanout", () => {
2347
2347
  .map((p) => path.relative(tmpHqRoot, p))
2348
2348
  .sort();
2349
2349
 
2350
- // free-co MUST be present (cloud:false, no membership, not _template).
2350
+ // free-co + zilch MUST be present (local, no membership, not _template).
2351
2351
  expect(rel).toContain(path.join("companies", "free-co"));
2352
+ expect(rel).toContain(path.join("companies", "zilch"));
2352
2353
  // knowledge/ MUST still be present (top-level walk unchanged).
2353
2354
  expect(rel).toContain("knowledge");
2354
2355
 
2355
- // All other companies MUST be filtered out.
2356
+ // Excluded companies MUST be filtered out.
2356
2357
  for (const forbidden of [
2357
2358
  path.join("companies", "acme"), // team-synced — goes to cmp_a's bucket
2358
2359
  path.join("companies", "_template"), // hard-listed
2359
- path.join("companies", "cloud-team"), // cloud:true
2360
- path.join("companies", "zilch"), // no marker
2360
+ path.join("companies", "cloud-team"), // cloud:true marker
2361
2361
  ]) {
2362
2362
  expect(rel).not.toContain(forbidden);
2363
2363
  }
@@ -2382,21 +2382,20 @@ describe("personal slot fanout", () => {
2382
2382
  }
2383
2383
  });
2384
2384
 
2385
- it("H: with HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL unset, companies/ subdirs are NEVER added to the personal sloteven with a valid cloud:false marker", async () => {
2386
- // Explicitly assert the OFF case: the env var is the gate. Without it,
2387
- // a `cloud: false` marker is necessary-but-insufficient; the personal
2388
- // vault stays in its pre-5.20 shape (top-level entries only, never
2389
- // any company subdir). Defensive `unstub` in case a leaked stub from
2390
- // an earlier test bleeds into this one.
2385
+ it("H: local company subdirs are added with NO env var set inclusion is unconditional (no gate)", async () => {
2386
+ // Regression guard against re-introducing a gate. The old behavior
2387
+ // required HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1; that gate is removed.
2388
+ // With the env var explicitly unset, a markerless local company MUST
2389
+ // still be pushed to the personal vault. Defensive `unstub` in case a
2390
+ // leaked stub from an earlier test bleeds into this one.
2391
2391
  vi.unstubAllEnvs();
2392
2392
  const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
2393
2393
  const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
2394
2394
  try {
2395
2395
  fs.mkdirSync(path.join(tmpHqRoot, "knowledge"));
2396
- // Same fixture as Gbut the gate is off, so free-co must NOT appear.
2396
+ // A markerless local companymust appear even with no env var.
2397
2397
  const dir = path.join(tmpHqRoot, "companies", "free-co");
2398
2398
  fs.mkdirSync(dir, { recursive: true });
2399
- fs.writeFileSync(path.join(dir, "company.yaml"), "cloud: false\n");
2400
2399
 
2401
2400
  const deps = makeDeps({
2402
2401
  createVaultClient: () =>
@@ -2423,9 +2422,9 @@ describe("personal slot fanout", () => {
2423
2422
  .map((p) => path.relative(tmpHqRoot, p))
2424
2423
  .sort();
2425
2424
 
2426
- // free-co must NOT appear because the env-var gate is off.
2427
- expect(rel).not.toContain(path.join("companies", "free-co"));
2428
- // knowledge/ must still appear — the legacy top-level walk is unchanged.
2425
+ // free-co MUST appear inclusion is unconditional, no env gate.
2426
+ expect(rel).toContain(path.join("companies", "free-co"));
2427
+ // knowledge/ must still appear — the top-level walk is unchanged.
2429
2428
  expect(rel).toContain("knowledge");
2430
2429
  } finally {
2431
2430
  fs.rmSync(tmpHqRoot, { recursive: true, force: true });
@@ -2495,17 +2494,16 @@ describe("personal slot fanout", () => {
2495
2494
  }
2496
2495
  });
2497
2496
 
2498
- it("J: personal slot's syncFn (pull) receives includeLocalCompanies + teamSyncedSlugs; company slots receive neither", async () => {
2497
+ it("J: personal slot's syncFn (pull) receives teamSyncedSlugs; company slots receive none", async () => {
2499
2498
  // Symmetric to test I (push-side decommissionPrefixes). The pull side
2500
- // needs the same context to:
2501
- // allow `companies/{cloud-false-slug}/...` keys through when the
2502
- // operator opts in (HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1)
2503
- // drop `companies/{team-synced-slug}/...` orphans even when push-
2504
- // side decommission hasn't run (e.g. pull-only mode)
2499
+ // needs the team-synced slug set to drop `companies/{team-synced-slug}/...`
2500
+ // orphans even when push-side decommission hasn't run (e.g. pull-only
2501
+ // mode). There is no longer an includeLocalCompanies gate — inclusion is
2502
+ // unconditional.
2505
2503
  //
2506
2504
  // Default direction for runRunner is "pull" so this test exercises
2507
2505
  // the pull call without needing --direction.
2508
- vi.stubEnv("HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL", "1");
2506
+ vi.unstubAllEnvs();
2509
2507
  const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
2510
2508
  try {
2511
2509
  const deps = makeDeps({
@@ -2528,26 +2526,24 @@ describe("personal slot fanout", () => {
2528
2526
  const code = await runRunner(["--companies"], deps);
2529
2527
  expect(code).toBe(0);
2530
2528
 
2531
- // Personal slot: both args present, populated with the team-synced
2532
- // slug set + the env-var-derived gate flag.
2529
+ // Personal slot: teamSyncedSlugs present, populated with the team-synced
2530
+ // slug set. No includeLocalCompanies key (the gate is gone).
2533
2531
  const personalCall = (syncSpy.mock.calls as Array<[SyncOptions]>).find(
2534
2532
  (c) => c[0].company?.startsWith("prs_"),
2535
2533
  );
2536
2534
  expect(personalCall).toBeDefined();
2537
2535
  const personalArgs = personalCall![0] as SyncOptions & {
2538
- includeLocalCompanies?: boolean;
2539
2536
  teamSyncedSlugs?: ReadonlySet<string>;
2540
2537
  };
2541
- expect(personalArgs.includeLocalCompanies).toBe(true);
2538
+ expect(Object.keys(personalArgs)).not.toContain("includeLocalCompanies");
2542
2539
  expect(Array.from(personalArgs.teamSyncedSlugs ?? []).sort()).toEqual([
2543
2540
  "acme",
2544
2541
  "beta",
2545
2542
  ]);
2546
2543
 
2547
- // Company slots: NEITHER key present (preserves the contract that
2548
- // company-target args stay identical to pre-Slice-2 shape symmetric
2549
- // to test C's personalMode/journalSlug pin and test I's
2550
- // decommissionPrefixes pin).
2544
+ // Company slots: no teamSyncedSlugs key (preserves the contract that
2545
+ // company-target args stay identical symmetric to test C's
2546
+ // personalMode/journalSlug pin and test I's decommissionPrefixes pin).
2551
2547
  const companyCalls = (syncSpy.mock.calls as Array<[SyncOptions]>).filter(
2552
2548
  (c) => c[0].company?.startsWith("cmp_"),
2553
2549
  );
@@ -2561,41 +2557,6 @@ describe("personal slot fanout", () => {
2561
2557
  }
2562
2558
  });
2563
2559
 
2564
- it("K: personal slot's syncFn (pull) sets includeLocalCompanies=false when env var is unset — pull stays in legacy shape", async () => {
2565
- // Symmetric to test H (env-var-OFF case for push). Without the gate,
2566
- // the pull side preserves the pre-5.20 "drop all companies/... keys"
2567
- // contract regardless of what's actually in the personal bucket.
2568
- // Defensive unstub in case a previous test leaked a stub.
2569
- vi.unstubAllEnvs();
2570
- const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
2571
- const deps = makeDeps({
2572
- createVaultClient: () =>
2573
- makeVaultStub({
2574
- memberships: [{ companyUid: "cmp_a" }],
2575
- entityGet: (uid: string) =>
2576
- Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
2577
- listPersons: () => Promise.resolve([olderPerson]),
2578
- }),
2579
- sync: syncSpy,
2580
- });
2581
-
2582
- const code = await runRunner(["--companies"], deps);
2583
- expect(code).toBe(0);
2584
-
2585
- const personalCall = (syncSpy.mock.calls as Array<[SyncOptions]>).find(
2586
- (c) => c[0].company?.startsWith("prs_"),
2587
- );
2588
- expect(personalCall).toBeDefined();
2589
- const personalArgs = personalCall![0] as SyncOptions & {
2590
- includeLocalCompanies?: boolean;
2591
- };
2592
- // Explicitly false — not "undefined" — because the runner always
2593
- // computes the value from the env var, then spreads it for the
2594
- // personal slot regardless of the value (allows downstream to know
2595
- // the gate was evaluated, not just "caller forgot to set it").
2596
- expect(personalArgs.includeLocalCompanies).toBe(false);
2597
- });
2598
-
2599
2560
  it("F: shareFn paths for company slots stay [hqRoot/companies/{slug}] with no personalMode/journalSlug keys", async () => {
2600
2561
  const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
2601
2562
  const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
@@ -895,7 +895,7 @@ describe("sync", () => {
895
895
  expect(fs.existsSync(journalPath)).toBe(false);
896
896
  });
897
897
 
898
- it("personalMode: true skips companies/* keys and downloads root keys to hqRoot", async () => {
898
+ it("personalMode: downloads local companies/* keys and root keys to hqRoot by default", async () => {
899
899
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
900
900
  { key: "companies/foo/bar.md", size: 50, lastModified: new Date(), etag: '"xyz789"' },
901
901
  { key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"abc000"' },
@@ -906,22 +906,24 @@ describe("sync", () => {
906
906
  vaultConfig: mockConfig,
907
907
  hqRoot: tmpDir,
908
908
  personalMode: true,
909
+ // No teamSyncedSlugs → foo is a local (non-cloud) company, downloaded.
909
910
  });
910
911
 
911
- // Exact counts (regression-tight)
912
- expect(result.filesSkipped).toBe(1);
913
- expect(result.filesDownloaded).toBe(1);
912
+ // Exact counts (regression-tight): local company + root key both land.
913
+ expect(result.filesSkipped).toBe(0);
914
+ expect(result.filesDownloaded).toBe(2);
914
915
 
915
- // companies/* must NOT land anywhere
916
+ // companies/foo (local, non-cloud) lands at <hqRoot>/companies/foo/bar.md,
917
+ // NOT double-nested under <hqRoot>/companies/<slug>/companies/...
918
+ expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(true);
916
919
  expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "companies", "foo", "bar.md"))).toBe(false);
917
- expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(false);
918
920
 
919
921
  // docs/readme.md MUST land at <hqRoot>/docs/readme.md (NOT <hqRoot>/companies/<slug>/docs/readme.md)
920
922
  expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
921
923
  expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "readme.md"))).toBe(false);
922
924
  });
923
925
 
924
- it("personalMode: downloads + journals companies/manifest.yaml (carve-out round-trips) while still skipping other companies/* keys", async () => {
926
+ it("personalMode: downloads + journals companies/manifest.yaml (carve-out round-trips) while still skipping team-synced orphan keys", async () => {
925
927
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
926
928
  { key: "companies/foo/bar.md", size: 50, lastModified: new Date(), etag: '"xyz789"' },
927
929
  { key: "companies/manifest.yaml", size: 40, lastModified: new Date(), etag: '"man111"' },
@@ -932,10 +934,12 @@ describe("sync", () => {
932
934
  vaultConfig: mockConfig,
933
935
  hqRoot: tmpDir,
934
936
  personalMode: true,
937
+ // foo is now cloud-backed (team-synced) → its key is a stale orphan.
938
+ teamSyncedSlugs: new Set(["foo"]),
935
939
  });
936
940
 
937
- // The manifest is the lone companies/* exemption: it downloads; other
938
- // companies/* keys are still dropped.
941
+ // The manifest always downloads (routing source-of-truth carve-out);
942
+ // the team-synced orphan key is dropped.
939
943
  expect(result.filesSkipped).toBe(1);
940
944
  expect(result.filesDownloaded).toBe(1);
941
945
  expect(fs.existsSync(path.join(tmpDir, "companies", "manifest.yaml"))).toBe(true);
@@ -1080,13 +1084,12 @@ describe("sync", () => {
1080
1084
  expect(kept.thread_path).toBe("workspace/threads/T-machineB.json");
1081
1085
  });
1082
1086
 
1083
- it("personalMode + includeLocalCompanies: downloads companies/{cloud-false-slug}/... keys when slug NOT in teamSyncedSlugs", async () => {
1084
- // The symmetric flip for the cloud:false personal-bucket fallback.
1085
- // Machine A pushed `companies/free-co/notes.md` to the personal bucket
1086
- // (because the operator set `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1`
1087
- // AND `company.yaml` declares `cloud: false`). Machine B subscribes:
1088
- // pull must allow these keys through the personalMode filter, otherwise
1089
- // the feature is push-only and the destination machine never sees them.
1087
+ it("personalMode: downloads companies/{local-slug}/... keys by default when slug NOT in teamSyncedSlugs", async () => {
1088
+ // Local (non-cloud) companies sync to the personal vault by default.
1089
+ // Machine A pushed `companies/free-co/notes.md` to the personal bucket;
1090
+ // machine B subscribes: pull must allow these keys through the
1091
+ // personalMode filter, otherwise the feature is push-only and the
1092
+ // destination machine never sees them.
1090
1093
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1091
1094
  { key: "companies/free-co/notes.md", size: 50, lastModified: new Date(), etag: '"abc"' },
1092
1095
  { key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"def"' },
@@ -1097,7 +1100,6 @@ describe("sync", () => {
1097
1100
  vaultConfig: mockConfig,
1098
1101
  hqRoot: tmpDir,
1099
1102
  personalMode: true,
1100
- includeLocalCompanies: true,
1101
1103
  teamSyncedSlugs: new Set(), // empty → no slug is "orphan"
1102
1104
  });
1103
1105
 
@@ -1107,21 +1109,20 @@ describe("sync", () => {
1107
1109
  expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
1108
1110
  });
1109
1111
 
1110
- it("personalMode + includeLocalCompanies: drops companies/{team-synced-slug}/... keys as orphans (symmetric to push-side decommission)", async () => {
1111
- // The orphan-cleanup half. Once a company has been promoted from
1112
- // cloud:false fallback to its own team bucket, the operator gains
1113
- // an active Membership for it (slug enters teamSyncedSlugs). Any
1114
- // leftover `companies/{that-slug}/...` keys in the personal bucket
1115
- // are stale residue downloading them would clash with the team-
1116
- // bucket pull at the same disk path. Filter drops them silently.
1117
- // Push-side `decommissionPrefixes` eventually removes them from the
1112
+ it("personalMode: drops companies/{team-synced-slug}/... keys as orphans (company became cloud-true)", async () => {
1113
+ // The orphan-cleanup half. Once a company becomes cloud-true, the
1114
+ // operator gains an active Membership for it (slug enters
1115
+ // teamSyncedSlugs). Any leftover `companies/{that-slug}/...` keys in
1116
+ // the personal bucket are stale residue — downloading them would clash
1117
+ // with the team-bucket pull at the same disk path. Filter drops them
1118
+ // silently. Push-side `decommissionPrefixes` removes them from the
1118
1119
  // bucket; this filter keeps the local tree clean in the meantime
1119
1120
  // (especially important for pull-only mode where decommission never
1120
1121
  // runs).
1121
1122
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1122
1123
  // Orphan: this slug is now team-synced — drop the key.
1123
1124
  { key: "companies/acme/file.md", size: 50, lastModified: new Date(), etag: '"old"' },
1124
- // Legitimate cloud:false content: download.
1125
+ // Legitimate local content: download.
1125
1126
  { key: "companies/free-co/notes.md", size: 30, lastModified: new Date(), etag: '"new"' },
1126
1127
  ]);
1127
1128
 
@@ -1130,7 +1131,6 @@ describe("sync", () => {
1130
1131
  vaultConfig: mockConfig,
1131
1132
  hqRoot: tmpDir,
1132
1133
  personalMode: true,
1133
- includeLocalCompanies: true,
1134
1134
  teamSyncedSlugs: new Set(["acme"]),
1135
1135
  });
1136
1136
 
@@ -1142,18 +1142,16 @@ describe("sync", () => {
1142
1142
  expect(fs.existsSync(path.join(tmpDir, "companies", "free-co", "notes.md"))).toBe(true);
1143
1143
  });
1144
1144
 
1145
- it("personalMode WITHOUT includeLocalCompanies: legacy behavior preserved ALL companies/... keys dropped", async () => {
1146
- // Regression for the legacy contract. Pre-5.20 (and any operator who
1147
- // hasn't opted into HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1), the
1148
- // personal bucket should never contain `companies/...` keys. If they
1149
- // do exist (stale data, manual S3 console intervention, an old bug),
1150
- // the pull-side filter drops them as a safety net. This test pins
1151
- // that contract under the new option shape: includeLocalCompanies
1152
- // omitted → behaves identically to the original filter, even if
1153
- // teamSyncedSlugs is supplied.
1145
+ it("personalMode: companies/manifest.yaml always downloads (routing source-of-truth), even when other slugs are team-synced", async () => {
1146
+ // The manifest is the one `companies/...` key carved into the personal
1147
+ // vault unconditionally. It must round-trip on pull even when the
1148
+ // operator has team-synced slugs (its key segment `manifest.yaml` is
1149
+ // not a real slug and must never be treated as an orphan).
1154
1150
  vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1155
- { key: "companies/anything/file.md", size: 50, lastModified: new Date(), etag: '"x"' },
1156
- { key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"y"' },
1151
+ { key: "companies/manifest.yaml", size: 40, lastModified: new Date(), etag: '"m"' },
1152
+ // A team-synced orphan that must still be dropped.
1153
+ { key: "companies/acme/file.md", size: 50, lastModified: new Date(), etag: '"o"' },
1154
+ { key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"d"' },
1157
1155
  ]);
1158
1156
 
1159
1157
  const result = await sync({
@@ -1161,13 +1159,13 @@ describe("sync", () => {
1161
1159
  vaultConfig: mockConfig,
1162
1160
  hqRoot: tmpDir,
1163
1161
  personalMode: true,
1164
- // includeLocalCompanies omitted → defaults to false
1165
- teamSyncedSlugs: new Set(["unrelated"]),
1162
+ teamSyncedSlugs: new Set(["acme"]),
1166
1163
  });
1167
1164
 
1168
- expect(result.filesDownloaded).toBe(1);
1165
+ expect(result.filesDownloaded).toBe(2);
1169
1166
  expect(result.filesSkipped).toBe(1);
1170
- expect(fs.existsSync(path.join(tmpDir, "companies", "anything", "file.md"))).toBe(false);
1167
+ expect(fs.existsSync(path.join(tmpDir, "companies", "manifest.yaml"))).toBe(true);
1168
+ expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "file.md"))).toBe(false);
1171
1169
  expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
1172
1170
  });
1173
1171
 
package/src/cli/sync.ts CHANGED
@@ -323,35 +323,21 @@ export interface SyncOptions {
323
323
  onEvent?: (event: SyncProgressEvent) => void;
324
324
  /**
325
325
  * When true, the caller is syncing against the caller's person-entity
326
- * bucket. Pulled keys whose path starts with `companies/` are dropped
327
- * by default (belt-and-braces the person bucket should never contain
328
- * those, and the runner must not write them into the user's company
329
- * folders). See `includeLocalCompanies` for the opt-in escape hatch.
326
+ * bucket. Pulled keys whose path starts with `companies/` are local
327
+ * (non-cloud) companies that sync to the personal vault by default
328
+ * they are allowed through EXCEPT for slugs in `teamSyncedSlugs` (which
329
+ * are cloud-backed orphans, see below). `companies/manifest.yaml` is
330
+ * always allowed (routing source-of-truth).
330
331
  */
331
332
  personalMode?: boolean;
332
- /**
333
- * Opt-in: when `personalMode === true`, allow `companies/{slug}/...` keys
334
- * through the pull filter EXCEPT for slugs in `teamSyncedSlugs` (which
335
- * are orphan remnants from a company that was promoted to its own team
336
- * bucket and remain pre-decommission). Mirrors the push-side
337
- * `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1` gate: when an operator opts
338
- * into the cloud:false → personal-bucket fallback on their machine,
339
- * pull must also know about the new key shape on the OTHER machine that
340
- * subscribes to those keys. Without this, the feature is push-only —
341
- * the destination machine never sees the keys.
342
- *
343
- * Default false preserves the pre-5.20 behavior (drop all
344
- * `companies/...` keys in personalMode).
345
- */
346
- includeLocalCompanies?: boolean;
347
333
  /**
348
334
  * Slugs of companies the operator has an active team-bucket Membership
349
- * for. Only consulted when `personalMode === true` AND
350
- * `includeLocalCompanies === true`: keys under `companies/{slug}/...`
351
- * for any slug in this set are dropped as orphans from a pre-promotion
352
- * personal-bucket fallback. The push-side decommission cycle eventually
353
- * removes these from the bucket; this filter prevents them from
354
- * re-downloading into the same disk paths the team-bucket pull manages.
335
+ * for. Only consulted when `personalMode === true`: keys under
336
+ * `companies/{slug}/...` for any slug in this set are dropped as orphans
337
+ * from a pre-promotion personal-bucket fallback (the company became
338
+ * cloud-true). The push-side decommission cycle removes these from the
339
+ * bucket; this filter prevents them from re-downloading into the same
340
+ * disk paths the team-bucket pull manages.
355
341
  */
356
342
  teamSyncedSlugs?: ReadonlySet<string>;
357
343
  /**
@@ -798,7 +784,6 @@ function planPull(run: PullRunContext): PullPlan {
798
784
  run.companyRoot,
799
785
  run.shouldSync,
800
786
  run.options.personalMode === true,
801
- run.options.includeLocalCompanies === true,
802
787
  run.options.teamSyncedSlugs ?? null,
803
788
  run.currentPrefixSet,
804
789
  run.fileTombstones,
@@ -1729,7 +1714,6 @@ function computePullPlan(
1729
1714
  companyRoot: string,
1730
1715
  shouldSync: (filePath: string, isDir?: boolean) => boolean,
1731
1716
  personalMode: boolean,
1732
- includeLocalCompanies: boolean,
1733
1717
  teamSyncedSlugs: ReadonlySet<string> | null,
1734
1718
  // Coalesced, company-relative prefixes the pull is scoped to (US-005).
1735
1719
  // `[""]` (the `all`-mode value) covers everything via `isCoveredByAny`, so
@@ -1774,18 +1758,13 @@ function computePullPlan(
1774
1758
  // fall through to download + journal like any personal file.
1775
1759
  remoteFile.key !== PERSONAL_VAULT_MANIFEST_KEY
1776
1760
  ) {
1777
- // Default: drop every `companies/...` key the legacy contract
1778
- // is that the personal bucket should never contain them.
1779
- //
1780
- // EXCEPTION: when the operator has opted into the cloud:false →
1781
- // personal-bucket fallback (HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1
1782
- // on this machine), keys under `companies/{slug}/...` are
1783
- // legitimate they're content a peer machine pushed for a
1784
- // company whose `company.yaml` declares `cloud: false`. Allow
1785
- // them through EXCEPT for slugs the operator has an active team-
1786
- // bucket Membership for: those are orphan remnants from before
1787
- // the company was promoted, and downloading them would clash
1788
- // with the team-bucket pull at the same disk path.
1761
+ // Local (non-cloud) companies sync to the personal vault: keys under
1762
+ // `companies/{slug}/...` are legitimate content a peer machine pushed
1763
+ // for a company that is not cloud-backed. Allow them through EXCEPT
1764
+ // for slugs the operator has an active team-bucket Membership for:
1765
+ // those are orphan remnants from before the company became cloud-true,
1766
+ // and downloading them would clash with the team-bucket pull at the
1767
+ // same disk path.
1789
1768
  //
1790
1769
  // Symmetric to the push-side `decommissionPrefixes` logic in
1791
1770
  // share.ts — both target the same orphan class, both honor the
@@ -1794,7 +1773,7 @@ function computePullPlan(
1794
1773
  const slug = remoteFile.key.split("/")[1] ?? "";
1795
1774
  const isTeamSyncedOrphan =
1796
1775
  teamSyncedSlugs !== null && slug !== "" && teamSyncedSlugs.has(slug);
1797
- if (!includeLocalCompanies || isTeamSyncedOrphan) {
1776
+ if (isTeamSyncedOrphan) {
1798
1777
  items.push({ action: "skip-personal-mode", remoteFile, localPath });
1799
1778
  continue;
1800
1779
  }
@@ -2138,12 +2117,16 @@ function computePullPlan(
2138
2117
  if (remoteKeySet.has(posixKey)) continue;
2139
2118
  const localPath = resolveContainedVaultPath(companyRoot, key);
2140
2119
  if (localPath === null) continue;
2141
- // PersonalMode key gating — mirror the download branch.
2120
+ // PersonalMode key gating — mirror the download branch. Local (non-cloud)
2121
+ // company keys are tombstone-eligible (a peer's delete should propagate),
2122
+ // but team-synced orphans are left alone (the team-bucket pull owns those
2123
+ // disk paths), and the manifest — the routing source-of-truth — is never
2124
+ // auto-tombstoned out of the personal vault.
2142
2125
  if (personalMode && key.startsWith("companies/")) {
2143
2126
  const slug = key.split("/")[1] ?? "";
2144
2127
  const isTeamSyncedOrphan =
2145
2128
  teamSyncedSlugs !== null && slug !== "" && teamSyncedSlugs.has(slug);
2146
- if (!includeLocalCompanies || isTeamSyncedOrphan) continue;
2129
+ if (isTeamSyncedOrphan || key === PERSONAL_VAULT_MANIFEST_KEY) continue;
2147
2130
  }
2148
2131
  // Ephemeral keys are filtered both directions; never tombstone-
2149
2132
  // propagate a conflict-mirror.
@@ -516,6 +516,25 @@ describe("PresignObjectIO.headObject", () => {
516
516
  expect(await io.headObject("gone")).toBeNull();
517
517
  });
518
518
 
519
+ it("returns null on a server-confirmed FILES_PRESIGN_NOT_FOUND (absence, not denial — no GET issued)", async () => {
520
+ // The presign lambda HEAD'd S3 and confirmed absence. Unlike a read
521
+ // DENIAL (Forbidden, below), this is unambiguous "object missing", so
522
+ // headObject reports absent (null) and the push creates the new object —
523
+ // never reaching a 403-masked presigned GET.
524
+ const { vault, setPresign } = makeVault();
525
+ setPresign([
526
+ {
527
+ key: "new.md",
528
+ op: "get",
529
+ error: "Object not found: 'new.md'",
530
+ code: "FILES_PRESIGN_NOT_FOUND",
531
+ },
532
+ ]);
533
+ const io = new PresignObjectIO(vault, COMPANY);
534
+ expect(await io.headObject("new.md")).toBeNull();
535
+ expect(fetchMock).not.toHaveBeenCalled();
536
+ });
537
+
519
538
  it("throws Forbidden when presign denies the key — denial is NOT absence", async () => {
520
539
  // Regression: pre-fix this returned null ("absent"), which made
521
540
  // share.ts's push guard (`if (remoteMeta)`) skip every conflict check
package/src/object-io.ts CHANGED
@@ -853,6 +853,17 @@ export class PresignObjectIO implements ObjectIO {
853
853
  throw err;
854
854
  }
855
855
  const row = results[0];
856
+ if (row?.code === "FILES_PRESIGN_NOT_FOUND") {
857
+ // The presign lambda HEAD'd S3 and confirmed the object does NOT
858
+ // exist — an unambiguous absence, distinct from the read DENIAL
859
+ // handled below. Report absent (null) so the push creates the new
860
+ // object. Without this, a brand-new file's first upload is blocked
861
+ // forever: its presigned GET 403s (S3 masks absence when the URL
862
+ // lacks `s3:ListBucket`) and routes through the access-denied skip
863
+ // path. The server's explicit NOT_FOUND is the ONLY safe absence
864
+ // signal — a 403 stays "unknown", never "missing" (see below).
865
+ return null;
866
+ }
856
867
  if (!row || row.error || !row.url) {
857
868
  // A per-key denial means the caller can't READ the key — it says
858
869
  // nothing about whether the object EXISTS. Pre-fix this returned